001// --------------------------------------------------------------------------------
002// Copyright 2002-2025 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 com.echothree.util.server.persistence.Session;
042import java.awt.RenderingHints;
043import java.awt.Transparency;
044import java.awt.image.BufferedImage;
045import java.io.ByteArrayOutputStream;
046import java.io.IOException;
047import javax.enterprise.context.ApplicationScoped;
048import javax.enterprise.inject.spi.CDI;
049import javax.imageio.IIOImage;
050import javax.imageio.ImageIO;
051import javax.imageio.ImageReader;
052import javax.imageio.ImageWriteParam;
053import javax.imageio.stream.ImageOutputStream;
054import javax.imageio.stream.MemoryCacheImageInputStream;
055import javax.imageio.stream.MemoryCacheImageOutputStream;
056
057@ApplicationScoped
058public class ItemDescriptionLogic
059        extends BaseLogic {
060
061    protected ItemDescriptionLogic() {
062        super();
063    }
064
065    public static ItemDescriptionLogic getInstance() {
066        return CDI.current().select(ItemDescriptionLogic.class).get();
067    }
068
069    public String getIndexDefaultItemDescriptionTypeName() {
070        var itemControl = Session.getModelController(ItemControl.class);
071        var itemDescriptionType = itemControl.getIndexDefaultItemDescriptionType();
072        
073        return itemDescriptionType.getLastDetail().getItemDescriptionTypeName();
074    }
075    
076    public boolean isImage(ItemDescriptionType itemDescriptionType) {
077        var mimeTypeUsageType = itemDescriptionType.getLastDetail().getMimeTypeUsageType();
078        var result = false;
079
080        if(mimeTypeUsageType != null) {
081            result = mimeTypeUsageType.getMimeTypeUsageTypeName().equals(MimeTypeUsageTypes.IMAGE.name());
082        }
083
084        return result;
085    }
086
087    // Find the first available parent ItemDescription.
088    public ItemDescription getBestParent(ItemControl itemControl, ItemDescriptionType itemDescriptionType, Item item, Language language) {
089        ItemDescription itemDescription = null;
090        var itemDescriptionTypeDetail = itemDescriptionType.getLastDetail();
091
092        if(itemDescriptionTypeDetail.getUseParentIfMissing()) {
093            var parentItemDescriptionType = itemDescriptionTypeDetail.getParentItemDescriptionType();
094
095            if(parentItemDescriptionType != null) {
096                var parentItemDescription = itemControl.getItemDescription(parentItemDescriptionType, item, language);
097
098                if(parentItemDescription == null) {
099                    // If there isn't a parent, or if the parent is scaled, then try the parent's parent
100                    itemDescription = getBestParent(itemControl, parentItemDescriptionType, item, language);
101                } else {
102                    // If the parent image wasn't scaled, then we'll use that one.
103                    itemDescription = parentItemDescription;
104                }
105            }
106        }
107
108        return itemDescription;
109    }
110    
111    // Find the first available parent ItemDescription based on the Party's preferred Language.
112    public ItemDescription getBestParent(final ItemDescriptionType itemDescriptionType, final Item item, final Party party) {
113        var itemControl = Session.getModelController(ItemControl.class);
114        var partyControl = Session.getModelController(PartyControl.class);
115        var language = party == null ? partyControl.getDefaultLanguage() : partyControl.getPreferredLanguage(party);
116        
117        return getBestParent(itemControl, itemDescriptionType, item, language);
118    }
119    
120    // Find the first available parent ItemDescription based on the Party's preferred Language.
121    public ItemDescription getBestParentUsingNames(final ExecutionErrorAccumulator eea, final String itemDescriptionTypeName, final Item item,
122            final Party party) {
123        var itemControl = Session.getModelController(ItemControl.class);
124        var itemDescriptionType = itemControl.getItemDescriptionTypeByName(itemDescriptionTypeName);
125        
126        if(itemDescriptionType == null) {
127            handleExecutionError(UnknownItemDescriptionTypeNameException.class, eea, ExecutionErrors.UnknownItemDescriptionTypeName.name(), itemDescriptionTypeName);
128        }
129        
130        return (eea == null ? false : eea.hasExecutionErrors()) ? null : getBestParent(itemDescriptionType, item, party);
131    }
132    
133    // Find the first available parent ItemDescription based on the Party's preferred Language.
134    public String getBestStringUsingNames(final ExecutionErrorAccumulator eea, final String itemDescriptionTypeName, final Item item, final Party party) {
135        var itemDescription = getBestParentUsingNames(eea, itemDescriptionTypeName, item, party);
136        String stringDescription = null;
137        
138        if(itemDescription != null) {
139            var mimeType = itemDescription.getLastDetail().getMimeType();
140            
141            if(mimeType == null) {
142                var itemControl = Session.getModelController(ItemControl.class);
143                var itemStringDescription = itemControl.getItemStringDescription(itemDescription);
144
145                stringDescription = itemStringDescription.getStringDescription();
146            } else {
147                handleExecutionError(InvalidItemDescriptionTypeException.class, eea, ExecutionErrors.InvalidItemDescriptionType.name(), itemDescriptionTypeName);
148            }
149        }
150        
151        return stringDescription;
152    }
153    
154    // Find the highest quality parent ItemDescription.
155    public ItemDescription getBestParentImage(ItemControl itemControl, ItemDescriptionType itemDescriptionType, Item item, Language language) {
156        ItemDescription itemDescription = null;
157        var parentItemDescriptionType = itemDescriptionType.getLastDetail().getParentItemDescriptionType();
158
159        if(parentItemDescriptionType != null) {
160            var parentItemDescription = itemControl.getItemDescription(parentItemDescriptionType, item, language);
161
162            if(parentItemDescription == null || itemControl.getItemImageDescription(parentItemDescription).getScaledFromParent()) {
163                // If there isn't a parent, or if the parent is scaled, then try the parent's parent
164                itemDescription = getBestParentImage(itemControl, parentItemDescriptionType, item, language);
165            } else {
166                // If the parent image wasn't scaled, then we'll use that one.
167                itemDescription = parentItemDescription;
168            }
169        }
170
171
172        return itemDescription;
173    }
174
175    public ImageReader getImageReader(MimeType mimeType, ItemBlobDescription itemBlobDescription) {
176        var memoryCacheImageInputStream = new MemoryCacheImageInputStream(itemBlobDescription.getBlobDescription().getByteArrayInputStream());
177        var imageReaders = ImageIO.getImageReadersByMIMEType(mimeType.getLastDetail().getMimeTypeName());
178        var imageReader = imageReaders.hasNext() ? imageReaders.next() : null;
179
180        if(imageReader != null) {
181            imageReader.setInput(memoryCacheImageInputStream);
182
183            try {
184                // If there isn't at least one image, then return null.
185                if(imageReader.getNumImages(true) == 0) {
186                    imageReader = null;
187                }
188            } catch (IOException ioe) {
189                // Nothing, height and width stay null.
190            }
191        }
192
193        return imageReader;
194    }
195
196    // Based on: http://today.java.net/pub/a/today/2007/04/03/perils-of-image-getscaledinstance.html
197    /**
198     * Convenience method that returns a scaled instance of the
199     * provided {@code BufferedImage}.
200     *
201     * @param img the original image to be scaled
202     * @param targetWidth the desired width of the scaled instance,
203     *    in pixels
204     * @param targetHeight the desired height of the scaled instance,
205     *    in pixels
206     * @param hint one of the rendering hints that corresponds to
207     *    {@code RenderingHints.KEY_INTERPOLATION} (e.g.
208     *    {@code RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR},
209     *    {@code RenderingHints.VALUE_INTERPOLATION_BILINEAR},
210     *    {@code RenderingHints.VALUE_INTERPOLATION_BICUBIC})
211     * @param higherQuality if true, this method will use a multi-step
212     *    scaling technique that provides higher quality than the usual
213     *    one-step technique (only useful in downscaling cases, where
214     *    {@code targetWidth} or {@code targetHeight} is
215     *    smaller than the original dimensions, and generally only when
216     *    the {@code BILINEAR} hint is specified)
217     * @return a scaled version of the original {@code BufferedImage}
218     */
219    public BufferedImage getScaledInstance(BufferedImage img, int targetWidth, int targetHeight, Object hint, boolean higherQuality) {
220        var type = (img.getTransparency() == Transparency.OPAQUE) ? BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;
221        var ret = (BufferedImage)img;
222        int w, h;
223
224        if(higherQuality) {
225            // Use multi-step technique: start with original size, then
226            // scale down in multiple passes with drawImage()
227            // until the target size is reached
228            w = img.getWidth();
229            h = img.getHeight();
230        } else {
231            // Use one-step technique: scale directly from original
232            // size to target size with a single drawImage() call
233            w = targetWidth;
234            h = targetHeight;
235        }
236
237        do {
238            if(higherQuality && w > targetWidth) {
239                w /= 2;
240                if(w < targetWidth) {
241                    w = targetWidth;
242                }
243            }
244
245            if(higherQuality && h > targetHeight) {
246                h /= 2;
247                if(h < targetHeight) {
248                    h = targetHeight;
249                }
250            }
251
252            var tmp = new BufferedImage(w, h, type);
253            var g2 = tmp.createGraphics();
254            g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, hint);
255            g2.drawImage(ret, 0, 0, w, h, null);
256            g2.dispose();
257
258            ret = tmp;
259        } while(w != targetWidth || h != targetHeight);
260
261        return ret;
262    }
263
264    // http://www.nearinfinity.com/blogs/jim_clark/thumbnail_generation_gotchas.html
265    public double scaleToFit(double w1, double h1, double w2, double h2) {
266        var scale = 1.0D;
267
268        if(w1 > h1) {
269            if(w1 > w2)
270                scale = w2 / w1;
271            h1 *= scale;
272            if(h1 > h2)
273                scale *= h2 / h1;
274        } else {
275            if(h1 > h2)
276                scale = h2 / h1;
277            w1 *= scale;
278            if(w1 > w2)
279                scale *= w2 / w1;
280            }
281
282        return scale;
283    }
284
285    public ItemDescription searchForItemDescription(ItemDescriptionType itemDescriptionType, Item item, Language language, BasePK createdBy) {
286        var itemControl = Session.getModelController(ItemControl.class);
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 itemControl = Session.getModelController(ItemControl.class);
473        var childItemDescriptionTypes = itemControl.getItemDescriptionTypesByParentItemDescriptionType(itemDescriptionType);
474
475        childItemDescriptionTypes.forEach((childItemDescriptionType) -> {
476            var childItemDescription = itemControl.getItemDescriptionForUpdate(childItemDescriptionType, item, language);
477            var childWasScaled = true;
478            if(childItemDescription != null) {
479                var childItemImageDescription = itemControl.getItemImageDescription(childItemDescription);
480
481                childWasScaled = childItemImageDescription.getScaledFromParent();
482
483                if(childWasScaled) {
484                    itemControl.deleteItemDescription(childItemDescription, deletedBy);
485                }
486            }
487
488            // If no child description existed, or if it was scaled, then go through and make sure there are no scaled children under it.
489            if (childWasScaled) {
490                deleteItemImageDescriptionChildren(childItemDescriptionType, item, language, deletedBy);
491            }
492        });
493    }
494    
495    public void deleteItemImageDescriptionChildren(ItemDescription itemDescription, BasePK deletedBy) {
496        var itemDescriptionDetail = itemDescription.getLastDetail();
497
498        deleteItemImageDescriptionChildren(itemDescriptionDetail.getItemDescriptionType(), itemDescriptionDetail.getItem(), itemDescriptionDetail.getLanguage(), deletedBy);
499    }
500
501    public void deleteItemDescription(ItemDescription itemDescription, BasePK deletedBy) {
502        var itemControl = Session.getModelController(ItemControl.class);
503        var mimeTypeUsageType = itemDescription.getLastDetail().getItemDescriptionType().getLastDetail().getMimeTypeUsageType();
504
505        itemControl.deleteItemDescription(itemDescription, deletedBy);
506
507        if(mimeTypeUsageType != null &&  mimeTypeUsageType.getMimeTypeUsageTypeName().equals(MimeTypeUsageTypes.IMAGE.name())) {
508            var itemImageDescription = itemControl.getItemImageDescription(itemDescription);
509
510            if(!itemImageDescription.getScaledFromParent()) {
511                ItemDescriptionLogic.getInstance().deleteItemImageDescriptionChildren(itemDescription, deletedBy);
512            }
513        }
514    }
515    
516    public void updateItemImageDescriptionTypeFromValue(ItemImageDescriptionTypeValue itemImageDescriptionTypeValue, BasePK updatedBy) {
517        var itemControl = Session.getModelController(ItemControl.class);
518
519        itemControl.updateItemImageDescriptionTypeFromValue(itemImageDescriptionTypeValue, updatedBy);
520
521        if(itemImageDescriptionTypeValue.getPreferredHeightHasBeenModified() || itemImageDescriptionTypeValue.getPreferredWidthHasBeenModified()
522                || itemImageDescriptionTypeValue.getPreferredMimeTypePKHasBeenModified() || itemImageDescriptionTypeValue.getQualityHasBeenModified()
523                || itemImageDescriptionTypeValue.getScaleFromParent()) {
524            itemControl.deleteItemDescriptions(itemControl.getScaledItemDescriptionsByItemDescriptionTypePKForUpdate(itemImageDescriptionTypeValue.getItemDescriptionTypePK()), updatedBy);
525        }
526    }
527
528    public void updateItemImageTypeFromValue(ItemImageTypeDetailValue itemImageTypeDetailValue, BasePK updatedBy) {
529        var itemControl = Session.getModelController(ItemControl.class);
530
531        itemControl.updateItemImageTypeFromValue(itemImageTypeDetailValue, updatedBy);
532
533        if(itemImageTypeDetailValue.getPreferredMimeTypePKHasBeenModified() || itemImageTypeDetailValue.getQualityHasBeenModified()) {
534            itemControl.deleteItemDescriptions(itemControl.getScaledItemDescriptionsByItemImageTypePKForUpdate(itemImageTypeDetailValue.getItemImageTypePK()), updatedBy);
535        }
536    }
537
538}