001// --------------------------------------------------------------------------------
002// Copyright 2002-2026 Echo Three, LLC
003//
004// Licensed under the Apache License, Version 2.0 (the "License");
005// you may not use this file except in compliance with the License.
006// You may obtain a copy of the License at
007//
008//     http://www.apache.org/licenses/LICENSE-2.0
009//
010// Unless required by applicable law or agreed to in writing, software
011// distributed under the License is distributed on an "AS IS" BASIS,
012// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013// See the License for the specific language governing permissions and
014// limitations under the License.
015// --------------------------------------------------------------------------------
016
017package com.echothree.model.control.item.server.logic;
018
019import com.echothree.model.control.core.common.EntityAttributeTypes;
020import com.echothree.model.control.core.common.MimeTypeUsageTypes;
021import com.echothree.model.control.item.common.exception.InvalidItemDescriptionTypeException;
022import com.echothree.model.control.item.common.exception.UnknownItemDescriptionTypeNameException;
023import com.echothree.model.control.item.server.control.ItemControl;
024import com.echothree.model.control.party.server.control.PartyControl;
025import com.echothree.model.data.core.server.entity.MimeType;
026import com.echothree.model.data.item.server.entity.Item;
027import com.echothree.model.data.item.server.entity.ItemBlobDescription;
028import com.echothree.model.data.item.server.entity.ItemDescription;
029import com.echothree.model.data.item.server.entity.ItemDescriptionType;
030import com.echothree.model.data.item.server.value.ItemImageDescriptionTypeValue;
031import com.echothree.model.data.item.server.value.ItemImageTypeDetailValue;
032import com.echothree.model.data.party.server.entity.Language;
033import com.echothree.model.data.party.server.entity.Party;
034import com.echothree.util.common.exception.PersistenceDatabaseException;
035import com.echothree.util.common.message.ExecutionErrors;
036import com.echothree.util.common.persistence.BasePK;
037import com.echothree.util.common.persistence.type.ByteArray;
038import com.echothree.util.server.control.BaseLogic;
039import com.echothree.util.server.message.ExecutionErrorAccumulator;
040import com.echothree.util.server.persistence.PersistenceUtils;
041import java.awt.RenderingHints;
042import java.awt.Transparency;
043import java.awt.image.BufferedImage;
044import java.io.ByteArrayOutputStream;
045import java.io.IOException;
046import javax.enterprise.context.ApplicationScoped;
047import javax.enterprise.inject.spi.CDI;
048import javax.imageio.IIOImage;
049import javax.imageio.ImageIO;
050import javax.imageio.ImageReader;
051import javax.imageio.ImageWriteParam;
052import javax.imageio.stream.ImageOutputStream;
053import javax.imageio.stream.MemoryCacheImageInputStream;
054import javax.imageio.stream.MemoryCacheImageOutputStream;
055import javax.inject.Inject;
056
057@ApplicationScoped
058public class ItemDescriptionLogic
059        extends BaseLogic {
060
061    @Inject
062    ItemControl itemControl;
063
064    @Inject
065    PartyControl partyControl;
066
067    protected ItemDescriptionLogic() {
068        super();
069    }
070
071    public static ItemDescriptionLogic getInstance() {
072        return CDI.current().select(ItemDescriptionLogic.class).get();
073    }
074
075    public String getIndexDefaultItemDescriptionTypeName() {
076        var itemDescriptionType = itemControl.getIndexDefaultItemDescriptionType();
077        
078        return itemDescriptionType.getLastDetail().getItemDescriptionTypeName();
079    }
080    
081    public boolean isImage(ItemDescriptionType itemDescriptionType) {
082        var mimeTypeUsageType = itemDescriptionType.getLastDetail().getMimeTypeUsageType();
083        var result = false;
084
085        if(mimeTypeUsageType != null) {
086            result = mimeTypeUsageType.getMimeTypeUsageTypeName().equals(MimeTypeUsageTypes.IMAGE.name());
087        }
088
089        return result;
090    }
091
092    // Find the first available parent ItemDescription.
093    public ItemDescription getBestParent(ItemControl itemControl, ItemDescriptionType itemDescriptionType, Item item, Language language) {
094        ItemDescription itemDescription = null;
095        var itemDescriptionTypeDetail = itemDescriptionType.getLastDetail();
096
097        if(itemDescriptionTypeDetail.getUseParentIfMissing()) {
098            var parentItemDescriptionType = itemDescriptionTypeDetail.getParentItemDescriptionType();
099
100            if(parentItemDescriptionType != null) {
101                var parentItemDescription = itemControl.getItemDescription(parentItemDescriptionType, item, language);
102
103                if(parentItemDescription == null) {
104                    // If there isn't a parent, or if the parent is scaled, then try the parent's parent
105                    itemDescription = getBestParent(itemControl, parentItemDescriptionType, item, language);
106                } else {
107                    // If the parent image wasn't scaled, then we'll use that one.
108                    itemDescription = parentItemDescription;
109                }
110            }
111        }
112
113        return itemDescription;
114    }
115    
116    // Find the first available parent ItemDescription based on the Party's preferred Language.
117    public ItemDescription getBestParent(final ItemDescriptionType itemDescriptionType, final Item item, final Party party) {
118        var language = party == null ? partyControl.getDefaultLanguage() : partyControl.getPreferredLanguage(party);
119        
120        return getBestParent(itemControl, itemDescriptionType, item, language);
121    }
122    
123    // Find the first available parent ItemDescription based on the Party's preferred Language.
124    public ItemDescription getBestParentUsingNames(final ExecutionErrorAccumulator eea, final String itemDescriptionTypeName, final Item item,
125            final Party party) {
126        var itemDescriptionType = itemControl.getItemDescriptionTypeByName(itemDescriptionTypeName);
127        
128        if(itemDescriptionType == null) {
129            handleExecutionError(UnknownItemDescriptionTypeNameException.class, eea, ExecutionErrors.UnknownItemDescriptionTypeName.name(), itemDescriptionTypeName);
130        }
131        
132        return (eea == null ? false : eea.hasExecutionErrors()) ? null : getBestParent(itemDescriptionType, item, party);
133    }
134    
135    // Find the first available parent ItemDescription based on the Party's preferred Language.
136    public String getBestStringUsingNames(final ExecutionErrorAccumulator eea, final String itemDescriptionTypeName, final Item item, final Party party) {
137        var itemDescription = getBestParentUsingNames(eea, itemDescriptionTypeName, item, party);
138        String stringDescription = null;
139        
140        if(itemDescription != null) {
141            var mimeType = itemDescription.getLastDetail().getMimeType();
142            
143            if(mimeType == null) {
144                var itemStringDescription = itemControl.getItemStringDescription(itemDescription);
145
146                stringDescription = itemStringDescription.getStringDescription();
147            } else {
148                handleExecutionError(InvalidItemDescriptionTypeException.class, eea, ExecutionErrors.InvalidItemDescriptionType.name(), itemDescriptionTypeName);
149            }
150        }
151        
152        return stringDescription;
153    }
154    
155    // Find the highest quality parent ItemDescription.
156    public ItemDescription getBestParentImage(ItemControl itemControl, ItemDescriptionType itemDescriptionType, Item item, Language language) {
157        ItemDescription itemDescription = null;
158        var parentItemDescriptionType = itemDescriptionType.getLastDetail().getParentItemDescriptionType();
159
160        if(parentItemDescriptionType != null) {
161            var parentItemDescription = itemControl.getItemDescription(parentItemDescriptionType, item, language);
162
163            if(parentItemDescription == null || itemControl.getItemImageDescription(parentItemDescription).getScaledFromParent()) {
164                // If there isn't a parent, or if the parent is scaled, then try the parent's parent
165                itemDescription = getBestParentImage(itemControl, parentItemDescriptionType, item, language);
166            } else {
167                // If the parent image wasn't scaled, then we'll use that one.
168                itemDescription = parentItemDescription;
169            }
170        }
171
172
173        return itemDescription;
174    }
175
176    public ImageReader getImageReader(MimeType mimeType, ItemBlobDescription itemBlobDescription) {
177        var memoryCacheImageInputStream = new MemoryCacheImageInputStream(itemBlobDescription.getBlobDescription().getByteArrayInputStream());
178        var imageReaders = ImageIO.getImageReadersByMIMEType(mimeType.getLastDetail().getMimeTypeName());
179        var imageReader = imageReaders.hasNext() ? imageReaders.next() : null;
180
181        if(imageReader != null) {
182            imageReader.setInput(memoryCacheImageInputStream);
183
184            try {
185                // If there isn't at least one image, then return null.
186                if(imageReader.getNumImages(true) == 0) {
187                    imageReader = null;
188                }
189            } catch (IOException ioe) {
190                // Nothing, height and width stay null.
191            }
192        }
193
194        return imageReader;
195    }
196
197    // Based on: http://today.java.net/pub/a/today/2007/04/03/perils-of-image-getscaledinstance.html
198    /**
199     * Convenience method that returns a scaled instance of the
200     * provided {@code BufferedImage}.
201     *
202     * @param img the original image to be scaled
203     * @param targetWidth the desired width of the scaled instance,
204     *    in pixels
205     * @param targetHeight the desired height of the scaled instance,
206     *    in pixels
207     * @param hint one of the rendering hints that corresponds to
208     *    {@code RenderingHints.KEY_INTERPOLATION} (e.g.
209     *    {@code RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR},
210     *    {@code RenderingHints.VALUE_INTERPOLATION_BILINEAR},
211     *    {@code RenderingHints.VALUE_INTERPOLATION_BICUBIC})
212     * @param higherQuality if true, this method will use a multi-step
213     *    scaling technique that provides higher quality than the usual
214     *    one-step technique (only useful in downscaling cases, where
215     *    {@code targetWidth} or {@code targetHeight} is
216     *    smaller than the original dimensions, and generally only when
217     *    the {@code BILINEAR} hint is specified)
218     * @return a scaled version of the original {@code BufferedImage}
219     */
220    public BufferedImage getScaledInstance(BufferedImage img, int targetWidth, int targetHeight, Object hint, boolean higherQuality) {
221        var type = (img.getTransparency() == Transparency.OPAQUE) ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;
222        var ret = (BufferedImage)img;
223        int w, h;
224
225        if(higherQuality) {
226            // Use multi-step technique: start with original size, then
227            // scale down in multiple passes with drawImage()
228            // until the target size is reached
229            w = img.getWidth();
230            h = img.getHeight();
231        } else {
232            // Use one-step technique: scale directly from original
233            // size to target size with a single drawImage() call
234            w = targetWidth;
235            h = targetHeight;
236        }
237
238        do {
239            if(higherQuality && w > targetWidth) {
240                w /= 2;
241                if(w < targetWidth) {
242                    w = targetWidth;
243                }
244            }
245
246            if(higherQuality && h > targetHeight) {
247                h /= 2;
248                if(h < targetHeight) {
249                    h = targetHeight;
250                }
251            }
252
253            var tmp = new BufferedImage(w, h, type);
254            var g2 = tmp.createGraphics();
255            g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, hint);
256            g2.drawImage(ret, 0, 0, w, h, null);
257            g2.dispose();
258
259            ret = tmp;
260        } while(w != targetWidth || h != targetHeight);
261
262        return ret;
263    }
264
265    // http://www.nearinfinity.com/blogs/jim_clark/thumbnail_generation_gotchas.html
266    public double scaleToFit(double w1, double h1, double w2, double h2) {
267        var scale = 1.0D;
268
269        if(w1 > h1) {
270            if(w1 > w2)
271                scale = w2 / w1;
272            h1 *= scale;
273            if(h1 > h2)
274                scale *= h2 / h1;
275        } else {
276            if(h1 > h2)
277                scale = h2 / h1;
278            w1 *= scale;
279            if(w1 > w2)
280                scale *= w2 / w1;
281            }
282
283        return scale;
284    }
285
286    public ItemDescription searchForItemDescription(ItemDescriptionType itemDescriptionType, Item item, Language language, BasePK createdBy) {
287        ItemDescription itemDescription = null;
288
289        if(isImage(itemDescriptionType)) {
290            var itemImageDescriptionType = itemControl.getItemImageDescriptionType(itemDescriptionType);
291
292            if(itemImageDescriptionType.getScaleFromParent()) {
293                var preferredHeight = itemImageDescriptionType.getPreferredHeight();
294                var preferredWidth = itemImageDescriptionType.getPreferredWidth();
295
296                // preferredHeight and preferredWidth are used as the target sizes for the scaling. Without them, it isn't going to happen.
297                if(preferredHeight != null && preferredWidth != null) {
298                    var originalItemDescription = getBestParentImage(itemControl, itemDescriptionType, item, language);
299
300                    if(originalItemDescription != null) {
301                        var originalItemDescriptionDetail = originalItemDescription.getLastDetail();
302                        var originalItemImageDescription = itemControl.getItemImageDescription(originalItemDescription);
303
304                        // BLOBs only.
305                        if(originalItemDescriptionDetail.getMimeType().getLastDetail().getEntityAttributeType().getEntityAttributeTypeName().equals(EntityAttributeTypes.BLOB.name())) {
306                            var originalMimeType = originalItemDescriptionDetail.getMimeType();
307                            var preferredMimeType = itemImageDescriptionType.getPreferredMimeType();
308                            var quality = itemImageDescriptionType.getQuality();
309                            var originalItemImageType = originalItemImageDescription.getItemImageType();
310                            var originalItemImageTypeDetail = originalItemImageType.getLastDetail();
311                            var originalItemBlobDescription = itemControl.getItemBlobDescription(originalItemDescription);
312
313                            // ItemImageType settings override any that came from the ItemImageDescriptionType
314                            if(originalItemImageTypeDetail.getPreferredMimeType() != null) {
315                                preferredMimeType = originalItemImageTypeDetail.getPreferredMimeType();
316                            }
317
318                            if(originalItemImageTypeDetail.getQuality() != null) {
319                                quality = originalItemImageTypeDetail.getQuality();
320                            }
321
322                            // If there's still no preferredMimeType, fall back to the original image's MimeType.
323                            if(preferredMimeType == null) {
324                                preferredMimeType = originalMimeType;
325                            }
326
327                            if(quality == null) {
328                                quality = 90; // Default quality
329                            }
330
331                            var imageReader = getImageReader(originalMimeType, originalItemBlobDescription);
332                            if(imageReader != null) {
333                                BufferedImage originalBufferedImage = null;
334
335                                try {
336                                    originalBufferedImage = imageReader.read(0);
337                                } catch (IOException ioe) {
338                                    // Ignore, image reading failed, leave originalBufferedImage null.
339                                }
340
341                                if(originalBufferedImage != null) {
342                                    var originalHeight = originalBufferedImage.getHeight();
343                                    var originalWidth = originalBufferedImage.getWidth();
344                                    var scale = scaleToFit(originalWidth, originalHeight, preferredWidth, preferredHeight);
345                                    var mimeTypeName = preferredMimeType.getLastDetail().getMimeTypeName();
346
347                                    var scaledBufferedImage = getScaledInstance(originalBufferedImage, (int)Math.round(originalWidth * scale),
348                                                                       (int)Math.round(originalHeight * scale), RenderingHints.VALUE_INTERPOLATION_BILINEAR,
349                                                                       true);
350
351                                    var imageWriters = ImageIO.getImageWritersByMIMEType(mimeTypeName);
352                                    var imageWriter = imageWriters.hasNext() ? imageWriters.next() : null;
353
354                                    if(imageWriter != null) {
355                                        var byteArrayOutputStream = new ByteArrayOutputStream();
356
357                                        try {
358                                            var scaledQuality = (float)quality / 100.0f;
359                                            var iwp = imageWriter.getDefaultWriteParam();
360
361                                            if(iwp.canWriteCompressed()) {
362                                                var compressionTypes = iwp.getCompressionTypes();
363
364                                                iwp.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
365
366                                                // JPEG doesn't appear to "need" this, but GIF throws an IllegalStateException without it. Pick the first choice.
367                                                if(compressionTypes.length > 0) {
368                                                    iwp.setCompressionType(compressionTypes[0]);
369                                                }
370
371                                                iwp.setCompressionQuality(scaledQuality);
372                                            }
373
374                                            ImageOutputStream imageOutputStream = new MemoryCacheImageOutputStream(byteArrayOutputStream);
375                                            var img = new IIOImage(scaledBufferedImage, null, null);
376
377                                            imageWriter.setOutput(imageOutputStream);
378                                            imageWriter.write(null, img, iwp);
379                                            imageWriter.dispose();
380                                            imageOutputStream.close();
381                                        } catch (IOException ioe) {
382                                            // Exception? Nuke the result, no scaling.
383                                            byteArrayOutputStream = null;
384                                        }
385
386                                        if(byteArrayOutputStream != null) {
387                                            try {
388                                                itemDescription = itemControl.createItemDescription(itemDescriptionType, item, language, preferredMimeType, createdBy);
389                                                itemControl.createItemImageDescription(itemDescription, originalItemImageType, scaledBufferedImage.getHeight(),
390                                                        scaledBufferedImage.getWidth(), true, createdBy);
391                                                itemControl.createItemBlobDescription(itemDescription, new ByteArray(byteArrayOutputStream.toByteArray()), createdBy);
392                                            } catch(PersistenceDatabaseException pde) {
393                                                if(PersistenceUtils.getInstance().isIntegrityConstraintViolation(pde)) {
394                                                    itemDescription = itemControl.getItemDescription(itemDescriptionType, item, language);
395                                                    
396                                                    if(itemDescription != null) {
397                                                        pde = null;
398                                                    }
399                                                }
400
401                                                if(pde != null) {
402                                                    throw pde;
403                                                }
404                                            }
405                                        }
406                                    }
407                                }
408                            }
409                        }
410                    }
411                }
412            } else {
413                itemDescription = getBestParent(itemControl, itemDescriptionType, item, language);
414            }
415        } else {
416            itemDescription = getBestParent(itemControl, itemDescriptionType, item, language);
417        }
418
419        return itemDescription;
420    }
421
422    public static class ImageDimensions {
423
424        private Integer height;
425        private Integer width;
426
427        public ImageDimensions(Integer height, Integer width) {
428            this.height = height;
429            this.width = width;
430        }
431
432        public Integer getHeight() {
433            return height;
434        }
435
436        public void setHeight(Integer height) {
437            this.height = height;
438        }
439
440        public Integer getWidth() {
441            return width;
442        }
443
444        public void setWidth(Integer width) {
445            this.width = width;
446        }
447
448    }
449
450    public ImageDimensions getImageDimensions(String mimeTypeName, ByteArray blobDescription) {
451        var memoryCacheImageInputStream = blobDescription.getMemoryCacheImageInputStream();
452        var imageReaders = ImageIO.getImageReadersByMIMEType(mimeTypeName);
453        var imageReader = imageReaders.hasNext() ? imageReaders.next() : null;
454        ImageDimensions result = null;
455
456        if(imageReader != null) {
457            imageReader.setInput(memoryCacheImageInputStream);
458
459            try {
460                if(imageReader.getNumImages(true) > 0) {
461                    result = new ImageDimensions(imageReader.getHeight(0), imageReader.getWidth(0));
462                }
463            } catch (IOException ioe) {
464                // Nothing, result stays null.
465            }
466        }
467
468        return result;
469    }
470
471    public void deleteItemImageDescriptionChildren(ItemDescriptionType itemDescriptionType, Item item, Language language, BasePK deletedBy) {
472        var childItemDescriptionTypes = itemControl.getItemDescriptionTypesByParentItemDescriptionType(itemDescriptionType);
473
474        childItemDescriptionTypes.forEach((childItemDescriptionType) -> {
475            var childItemDescription = itemControl.getItemDescriptionForUpdate(childItemDescriptionType, item, language);
476            var childWasScaled = true;
477            if(childItemDescription != null) {
478                var childItemImageDescription = itemControl.getItemImageDescription(childItemDescription);
479
480                childWasScaled = childItemImageDescription.getScaledFromParent();
481
482                if(childWasScaled) {
483                    itemControl.deleteItemDescription(childItemDescription, deletedBy);
484                }
485            }
486
487            // If no child description existed, or if it was scaled, then go through and make sure there are no scaled children under it.
488            if (childWasScaled) {
489                deleteItemImageDescriptionChildren(childItemDescriptionType, item, language, deletedBy);
490            }
491        });
492    }
493    
494    public void deleteItemImageDescriptionChildren(ItemDescription itemDescription, BasePK deletedBy) {
495        var itemDescriptionDetail = itemDescription.getLastDetail();
496
497        deleteItemImageDescriptionChildren(itemDescriptionDetail.getItemDescriptionType(), itemDescriptionDetail.getItem(), itemDescriptionDetail.getLanguage(), deletedBy);
498    }
499
500    public void deleteItemDescription(ItemDescription itemDescription, BasePK deletedBy) {
501        var mimeTypeUsageType = itemDescription.getLastDetail().getItemDescriptionType().getLastDetail().getMimeTypeUsageType();
502
503        itemControl.deleteItemDescription(itemDescription, deletedBy);
504
505        if(mimeTypeUsageType != null &&  mimeTypeUsageType.getMimeTypeUsageTypeName().equals(MimeTypeUsageTypes.IMAGE.name())) {
506            var itemImageDescription = itemControl.getItemImageDescription(itemDescription);
507
508            if(!itemImageDescription.getScaledFromParent()) {
509                this.deleteItemImageDescriptionChildren(itemDescription, deletedBy);
510            }
511        }
512    }
513    
514    public void updateItemImageDescriptionTypeFromValue(ItemImageDescriptionTypeValue itemImageDescriptionTypeValue, BasePK updatedBy) {
515        itemControl.updateItemImageDescriptionTypeFromValue(itemImageDescriptionTypeValue, updatedBy);
516
517        if(itemImageDescriptionTypeValue.getPreferredHeightHasBeenModified() || itemImageDescriptionTypeValue.getPreferredWidthHasBeenModified()
518                || itemImageDescriptionTypeValue.getPreferredMimeTypePKHasBeenModified() || itemImageDescriptionTypeValue.getQualityHasBeenModified()
519                || itemImageDescriptionTypeValue.getScaleFromParent()) {
520            itemControl.deleteItemDescriptions(itemControl.getScaledItemDescriptionsByItemDescriptionTypePKForUpdate(itemImageDescriptionTypeValue.getItemDescriptionTypePK()), updatedBy);
521        }
522    }
523
524    public void updateItemImageTypeFromValue(ItemImageTypeDetailValue itemImageTypeDetailValue, BasePK updatedBy) {
525        itemControl.updateItemImageTypeFromValue(itemImageTypeDetailValue, updatedBy);
526
527        if(itemImageTypeDetailValue.getPreferredMimeTypePKHasBeenModified() || itemImageTypeDetailValue.getQualityHasBeenModified()) {
528            itemControl.deleteItemDescriptions(itemControl.getScaledItemDescriptionsByItemImageTypePKForUpdate(itemImageTypeDetailValue.getItemImageTypePK()), updatedBy);
529        }
530    }
531
532}