diff --git a/color-extractor-ui/src/app/color-input-display/color-input-display.component.css b/color-extractor-ui/src/app/color-input-display/color-input-display.component.css
index 23386a3..2a2e931 100644
--- a/color-extractor-ui/src/app/color-input-display/color-input-display.component.css
+++ b/color-extractor-ui/src/app/color-input-display/color-input-display.component.css
@@ -109,12 +109,12 @@ h2 {
margin-bottom: 10px;
}
-.color-info p {
+.color-details p {
margin: 5px 0;
font-size: 14px;
word-break: break-all;
}
-.color-info p strong {
+.color-details p strong {
color: #555;
}
diff --git a/pom.xml b/pom.xml
index fcbbd45..b601177 100644
--- a/pom.xml
+++ b/pom.xml
@@ -26,6 +26,11 @@
jsoup
1.15.3
+
+ org.apache.commons
+ commons-imaging
+ 1.0.0-alpha6
+
org.projectlombok
lombok
diff --git a/src/main/java/com/example/colorschemeidentifier/service/ColorExtractionService.java b/src/main/java/com/example/colorschemeidentifier/service/ColorExtractionService.java
index 52d2bf7..f6cacc9 100644
--- a/src/main/java/com/example/colorschemeidentifier/service/ColorExtractionService.java
+++ b/src/main/java/com/example/colorschemeidentifier/service/ColorExtractionService.java
@@ -10,9 +10,13 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
+import org.apache.commons.imaging.Imaging;
+import org.apache.commons.imaging.ImageReadException;
+
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
// import java.io.ByteArrayOutputStream; // No longer strictly needed after refactor
import java.io.IOException;
import java.io.InputStream;
@@ -448,30 +452,91 @@ private void extractColorsFromLogoImage(Set logoUrlSet, Map 0) {
+ dataUriMimeType = logoUrl.substring(5, mimeEnd).toLowerCase();
+ }
+ }
+ boolean isIcoByMime = "image/x-icon".equals(dataUriMimeType) || "image/vnd.microsoft.icon".equals(dataUriMimeType);
+
+ imageStream = logoUrl.startsWith("data:image")
+ ? processDataUriGetStream(logoUrl, sourceIdentifier) // This returns ByteArrayInputStream
+ : openConnectionAndGetStream(logoUrl); // This returns HttpInputStream
if (imageStream == null) {
logger.warn("Could not get input stream for logo URL: {}", logoUrl);
return;
}
- try (InputStream in = imageStream) { // Ensure stream is closed
- BufferedImage image = ImageIO.read(in);
- if (image != null) {
- logger.info("Processing logo image: {}", sourceIdentifier);
- // Calls the internal method, setting isLogoColor to true
- extractDominantColorsFromImageInternal(image, colorFrequencies, sourceIdentifier, true);
- } else {
- logger.warn("Could not decode logo image from source: {}", sourceIdentifier);
+ // For ICO, we need to buffer the stream if it's not already a ByteArrayInputStream,
+ // because Apache Commons Imaging might need to read it multiple times or it might not support mark/reset on HttpInputStream.
+ // ImageIO.read also benefits from a resettable stream for some formats.
+ if (!imageStream.markSupported()) {
+ imageStream = new ByteArrayInputStream(imageStream.readAllBytes());
+ }
+ imageStream.mark(Integer.MAX_VALUE); // Mark the beginning of the stream
+
+
+ if (isIcoByExtension || isIcoByMime) {
+ logger.info("Attempting to decode ICO logo with Apache Commons Imaging: {}", sourceIdentifier);
+ try {
+ image = Imaging.getBufferedImage(imageStream);
+ logger.info("Successfully decoded ICO logo with Apache Commons Imaging: {}", sourceIdentifier);
+ } catch (ImageReadException | IOException imagingEx) {
+ logger.warn("Apache Commons Imaging failed to decode logo ({}): {}. Attempting fallback to ImageIO.", sourceIdentifier, imagingEx.getMessage());
+ imageStream.reset(); // Reset stream for ImageIO
+ try {
+ image = ImageIO.read(imageStream);
+ if (image != null) {
+ logger.info("Successfully decoded ICO logo with ImageIO fallback: {}", sourceIdentifier);
+ } else {
+ logger.warn("ImageIO fallback also failed to decode ICO logo: {}", sourceIdentifier);
+ }
+ } catch (IOException imageIoEx) {
+ logger.warn("ImageIO fallback failed with IOException for ICO logo ({}): {}", sourceIdentifier, imageIoEx.getMessage());
+ }
+ }
+ } else {
+ logger.info("Attempting to decode non-ICO logo with ImageIO: {}", sourceIdentifier);
+ try {
+ image = ImageIO.read(imageStream);
+ if (image != null) {
+ logger.info("Successfully decoded non-ICO logo with ImageIO: {}", sourceIdentifier);
+ } else {
+ // This case is important: ImageIO.read can return null for unsupported formats without throwing an exception.
+ logger.warn("ImageIO.read returned null for logo (likely unsupported format or corrupt image): {}", sourceIdentifier);
+ }
+ } catch (IOException imageIoEx) {
+ logger.warn("ImageIO failed to decode non-ICO logo ({}) with IOException: {}", sourceIdentifier, imageIoEx.getMessage());
}
}
- } catch (IOException e) {
- logger.error("Error reading logo image stream for {}: {}", sourceIdentifier, e.getMessage());
- } catch (Exception e) {
+
+ if (image != null) {
+ extractDominantColorsFromImageInternal(image, colorFrequencies, sourceIdentifier, true);
+ } else {
+ logger.warn("Could not decode logo image from source (all attempts failed): {}", sourceIdentifier);
+ }
+
+ } catch (IOException e) { // Catches IO errors from stream opening or readAllBytes
+ logger.error("IOException during logo image processing for {}: {}", sourceIdentifier, e.getMessage());
+ } catch (Exception e) { // Catches other unexpected errors
logger.error("An unexpected error occurred while processing logo image {}: {}", sourceIdentifier, e.getMessage(), e);
+ } finally {
+ if (imageStream != null) {
+ try {
+ imageStream.close();
+ } catch (IOException e) {
+ logger.error("Failed to close image stream for {}: {}", sourceIdentifier, e.getMessage());
+ }
+ }
}
}
diff --git a/src/test/java/com/example/colorschemeidentifier/service/ColorExtractionServiceTest.java b/src/test/java/com/example/colorschemeidentifier/service/ColorExtractionServiceTest.java
index 2847db3..567f8c7 100644
--- a/src/test/java/com/example/colorschemeidentifier/service/ColorExtractionServiceTest.java
+++ b/src/test/java/com/example/colorschemeidentifier/service/ColorExtractionServiceTest.java
@@ -588,7 +588,7 @@ void extractColorsFromUrl_withLogoProcessing() throws IOException {
// Renamed to reflect it tests the internal algorithm if made accessible
@Test
void testExtractDominantColorsDirectlyInternal_logic() throws Exception {
- BufferedImage image = new BufferedImage(3, 1, BufferedImage.TYPE_INT_ARGB);
+ BufferedImage image = new BufferedImage(3, 1, BufferedImage.TYPE_INT_ARGB); // A sample image
image.setRGB(0, 0, new java.awt.Color(255, 0, 0).getRGB()); // Red
image.setRGB(1, 0, new java.awt.Color(0, 255, 0).getRGB()); // Green
image.setRGB(2, 0, new java.awt.Color(255, 0, 0).getRGB()); // Red
@@ -628,6 +628,150 @@ void testExtractDominantColorsDirectlyInternal_logic() throws Exception {
assertEquals("logo:logo_image.png", colorFrequencies.get("#20E020").getColorInfo().getSource());
}
+ // Helper method to create a dummy BufferedImage
+ private BufferedImage createDummyImage(int width, int height, int color) {
+ BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ for (int x = 0; x < width; x++) {
+ for (int y = 0; y < height; y++) {
+ img.setRGB(x, y, color);
+ }
+ }
+ return img;
+ }
+
+ private void setupMockDocumentForLogoUrl(Document mockDoc, String logoUrlToReturn) {
+ when(mockDoc.select("link[rel=icon], link[rel~=(?i)shortcut icon]")).thenAnswer(inv -> {
+ Elements mockIconLinks = new Elements();
+ Element mockLinkElement = mock(Element.class);
+ when(mockLinkElement.absUrl("href")).thenReturn(logoUrlToReturn);
+ mockIconLinks.add(mockLinkElement);
+ return mockIconLinks;
+ });
+ when(mockDoc.baseUri()).thenReturn(TEST_BASE_URL);
+ // Prevent other extractions by returning empty elements for other selectors
+ when(mockDoc.select(not(eq("link[rel=icon], link[rel~=(?i)shortcut icon]")))).thenReturn(new Elements());
+ }
+
+ @Test
+ void icoLogo_DecodedByCommonsImaging_Success() throws IOException {
+ String logoUrl = TEST_BASE_URL + "logo.ico";
+ Document mockDoc = mock(Document.class);
+ setupMockDocumentForLogoUrl(mockDoc, logoUrl);
+ BufferedImage mockIcoImage = createDummyImage(1,1, java.awt.Color.GREEN.getRGB()); // Green -> #20E020
+
+ try (MockedStatic mockedJsoup = Mockito.mockStatic(Jsoup.class);
+ MockedStatic mockedImaging = Mockito.mockStatic(org.apache.commons.imaging.Imaging.class);
+ MockedStatic mockedImageIO = Mockito.mockStatic(ImageIO.class)) {
+
+ Connection mockMainConnection = mock(Connection.class);
+ mockedJsoup.when(() -> Jsoup.connect(TEST_URL)).thenReturn(mockMainConnection);
+ when(mockMainConnection.timeout(anyInt())).thenReturn(mockMainConnection);
+ when(mockMainConnection.get()).thenReturn(mockDoc);
+
+ mockedImaging.when(() -> org.apache.commons.imaging.Imaging.getBufferedImage(any(InputStream.class)))
+ .thenReturn(mockIcoImage);
+ mockedImageIO.when(() -> ImageIO.read(any(InputStream.class))).thenThrow(new AssertionError("ImageIO.read should not be called"));
+
+ List result = colorExtractionService.extractColorsFromUrl(TEST_URL);
+
+ assertEquals(1, result.size());
+ assertEquals("#20E020", result.get(0).getHexValue()); // Quantized Green
+ assertTrue(result.get(0).isLogoColor());
+ assertEquals("logo:" + logoUrl, result.get(0).getSource());
+ mockedImaging.verify(() -> org.apache.commons.imaging.Imaging.getBufferedImage(any(InputStream.class)), times(1));
+ mockedImageIO.verify(() -> ImageIO.read(any(InputStream.class)), never());
+ }
+ }
+
+ @Test
+ void icoLogo_CommonsImagingFails_ImageIOSucceeds() throws IOException {
+ String logoUrl = TEST_BASE_URL + "favicon.ico";
+ Document mockDoc = mock(Document.class);
+ setupMockDocumentForLogoUrl(mockDoc, logoUrl);
+ BufferedImage mockFallbackImage = createDummyImage(1,1, java.awt.Color.RED.getRGB()); // Red -> #E02020
+
+
+ try (MockedStatic mockedJsoup = Mockito.mockStatic(Jsoup.class);
+ MockedStatic mockedImaging = Mockito.mockStatic(org.apache.commons.imaging.Imaging.class);
+ MockedStatic mockedImageIO = Mockito.mockStatic(ImageIO.class)) {
+
+ Connection mockMainConnection = mock(Connection.class);
+ mockedJsoup.when(() -> Jsoup.connect(TEST_URL)).thenReturn(mockMainConnection);
+ when(mockMainConnection.timeout(anyInt())).thenReturn(mockMainConnection);
+ when(mockMainConnection.get()).thenReturn(mockDoc);
+
+ mockedImaging.when(() -> org.apache.commons.imaging.Imaging.getBufferedImage(any(InputStream.class)))
+ .thenThrow(new org.apache.commons.imaging.ImageReadException("Commons Imaging test error"));
+ mockedImageIO.when(() -> ImageIO.read(any(InputStream.class)))
+ .thenReturn(mockFallbackImage);
+
+ List result = colorExtractionService.extractColorsFromUrl(TEST_URL);
+
+ assertEquals(1, result.size());
+ assertEquals("#E02020", result.get(0).getHexValue()); // Quantized Red
+ assertTrue(result.get(0).isLogoColor());
+ mockedImaging.verify(() -> org.apache.commons.imaging.Imaging.getBufferedImage(any(InputStream.class)), times(1));
+ mockedImageIO.verify(() -> ImageIO.read(any(InputStream.class)), times(1));
+ }
+ }
+
+ @Test
+ void icoLogo_CommonsImagingFails_ImageIOFails() throws IOException {
+ String logoUrl = TEST_BASE_URL + "another.ico";
+ Document mockDoc = mock(Document.class);
+ setupMockDocumentForLogoUrl(mockDoc, logoUrl);
+
+ try (MockedStatic mockedJsoup = Mockito.mockStatic(Jsoup.class);
+ MockedStatic mockedImaging = Mockito.mockStatic(org.apache.commons.imaging.Imaging.class);
+ MockedStatic mockedImageIO = Mockito.mockStatic(ImageIO.class)) {
+
+ Connection mockMainConnection = mock(Connection.class);
+ mockedJsoup.when(() -> Jsoup.connect(TEST_URL)).thenReturn(mockMainConnection);
+ when(mockMainConnection.timeout(anyInt())).thenReturn(mockMainConnection);
+ when(mockMainConnection.get()).thenReturn(mockDoc);
+
+ mockedImaging.when(() -> org.apache.commons.imaging.Imaging.getBufferedImage(any(InputStream.class)))
+ .thenThrow(new org.apache.commons.imaging.ImageReadException("Commons Imaging test error"));
+ mockedImageIO.when(() -> ImageIO.read(any(InputStream.class)))
+ .thenReturn(null); // ImageIO.read returns null for failure
+
+ List result = colorExtractionService.extractColorsFromUrl(TEST_URL);
+
+ assertTrue(result.isEmpty(), "No colors should be extracted if logo decoding fails completely.");
+ mockedImaging.verify(() -> org.apache.commons.imaging.Imaging.getBufferedImage(any(InputStream.class)), times(1));
+ mockedImageIO.verify(() -> ImageIO.read(any(InputStream.class)), times(1));
+ }
+ }
+
+ @Test
+ void pngLogo_DecodedByImageIO_CommonsImagingSkipped() throws IOException {
+ String logoUrl = TEST_BASE_URL + "logo.png"; // Non-ICO
+ Document mockDoc = mock(Document.class);
+ setupMockDocumentForLogoUrl(mockDoc, logoUrl);
+ BufferedImage mockPngImage = createDummyImage(1,1, java.awt.Color.CYAN.getRGB()); // Cyan -> #20E0E0
+
+ try (MockedStatic mockedJsoup = Mockito.mockStatic(Jsoup.class);
+ MockedStatic mockedImaging = Mockito.mockStatic(org.apache.commons.imaging.Imaging.class);
+ MockedStatic mockedImageIO = Mockito.mockStatic(ImageIO.class)) {
+
+ Connection mockMainConnection = mock(Connection.class);
+ mockedJsoup.when(() -> Jsoup.connect(TEST_URL)).thenReturn(mockMainConnection);
+ when(mockMainConnection.timeout(anyInt())).thenReturn(mockMainConnection);
+ when(mockMainConnection.get()).thenReturn(mockDoc);
+
+ mockedImageIO.when(() -> ImageIO.read(any(InputStream.class)))
+ .thenReturn(mockPngImage);
+
+ List result = colorExtractionService.extractColorsFromUrl(TEST_URL);
+
+ assertEquals(1, result.size());
+ assertEquals("#20E0E0", result.get(0).getHexValue()); // Quantized Cyan
+ assertTrue(result.get(0).isLogoColor());
+ // Commons Imaging should not be called for a .png
+ mockedImaging.verify(() -> org.apache.commons.imaging.Imaging.getBufferedImage(any(InputStream.class)), never());
+ mockedImageIO.verify(() -> ImageIO.read(any(InputStream.class)), times(1));
+ }
+ }
@Test
void testFrequencyCountingAndTopN() throws IOException {