This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 20d127626a TIKA-4937: parse ICO and CUR files for image dimensions 
(#3266)
20d127626a is described below

commit 20d127626aad434f8b4b4c0a37a7bc6a5063f79a
Author: Dominik Schmidt <[email protected]>
AuthorDate: Tue Oct 6 21:26:58 2026 +0200

    TIKA-4937: parse ICO and CUR files for image dimensions (#3266)
    
    * TIKA-4937: parse ICO and CUR files for image dimensions
    
    ImageParser claimed image/x-icon but ImageIO has no ICO reader, so a .ico
    yielded nothing beyond its content type. The new ICOParser reads the
    ICONDIR and the header of every image (PNG IHDR or BITMAPINFOHEADER; the
    directory's own fields are unreliable and only the fallback) and sets
    tiff:ImageWidth/ImageLength/BitsPerSample of the largest image (larger
    area first, then higher colour depth), icon:image-count, one icon:images
    value per image (WxH@bpp encoding) and, for cursors, icon:hotspot-x/y.
    Nothing is decoded. Images beyond the end of the file or without a
    readable header are counted in a warning, never an exception.
    
    Adds image/x-win-bitmap (Windows cursor, *.cur, magic 00 00 02 00) to
    tika-mimetypes.xml, registers the new icon: keys in the metadata schema,
    removes image/x-icon from ImageParser and documents the parser. Fixtures
    are generated: an icon with 16/32 px BMP and 256 px PNG images, a BMP-only
    icon and a two-image cursor.
    
    * TIKA-4937: register ICOParser in the parser-order baseline, exclude 
cursors from raster bindings
    
    The 2.4.1 parser-order snapshots that TestParsers checks against now map
    image/vnd.microsoft.icon to ICOParser, and image/x-win-bitmap joins the
    icon types that InferenceLoader keeps out of the default raster image
    bindings, since no embedding endpoint takes a cursor either.
    
    * TIKA-4937: report colour depth per sample, keep cursor fallback honest, 
accept image/x-icon
    
    tiff:BitsPerSample means bits per channel elsewhere in Tika, so a 32 bpp
    icon now reports 8 bits per sample and 4 samples per pixel; the total
    stays in the per-image icon:images list. A cursor's directory entry holds
    the hotspot where an icon's holds the bit count, so a cursor whose image
    header cannot be read no longer takes its depth from the hotspot. The
    legacy alias image/x-icon is listed as supported, as ImageParser did.
    
    * TIKA-4937: count images without a readable header in the warning
    
    An entry inside the file whose data starts with neither a PNG signature
    nor a BITMAPINFOHEADER is still listed with what the directory says, but
    it now counts towards the warning like an entry beyond the end of the
    file, as the parser's contract promises.
    
    * TIKA-4937: address review: OS/2 bitmap arrays, bounded dimensions, 16 bpp 
listing, empty and cut headers
    
    OS/2 bitmap arrays share the icon type's magic; they now pass through
    with only the content type instead of failing as "not an icon". A width
    or height that is zero, negative or above 65535 makes the image header
    unreadable, so the directory values stand in and the image counts
    towards the warning. A 16 bpp image is listed as 16 bpp, not as the 15
    its three 5 bit samples add up to. An empty directory and a header cut
    before the image count are warnings.
    
    Also regenerates supported-formats.adoc for image/x-icon, sets @since to
    4.2.0 and adds the CHANGES entry.
    
    * TIKA-4937: ignore colour depths no DIB or PNG can have
    
    A BITMAPINFOHEADER or directory bit count other than 1, 4, 8, 16, 24 or
    32, and a PNG bit depth or colour type the format does not define, no
    longer reach tiff:BitsPerSample or the icon:images list. The depth
    known so far, the directory's for an icon, stands in.
    
    * TIKA-4937: require an IHDR chunk behind the PNG signature
    
    An image that starts with the PNG signature but not with a 13 byte IHDR
    chunk is no longer taken for a PNG: its dimensions came from whatever
    bytes stood at the offsets. It falls back to the directory's values and
    counts towards the warning like any other unreadable header.
    
    Adds tests for the colour depth tie-break between images of one size and
    for the cursor type's exclusion from the default raster bindings, and
    corrects the javadoc of icon:images and of testDirectoryFallback.
    
    * TIKA-4937: read fixture offsets from the directory in ICOParserTest
    
    The tests located the images of the fixtures by literal offsets repeated
    across the class; they now ask the directory entry.
    
    * TIKA-4937: icon:image-count is the number of images found
    
    The key repeated the count the header declares, so a file cut inside its
    directory, or one that claims 65535 images, reported images it does not
    hold. It now counts the images icon:images lists; the declared count
    stays in the warning ("2 of 3 images ...").
    
    ---------
    
    Co-authored-by: Tilman Hausherr <[email protected]>
---
 CHANGES.txt                                        |   6 +
 docs/modules/ROOT/pages/formats.adoc               |   9 +-
 docs/modules/ROOT/partials/supported-formats.adoc  |   5 +-
 .../main/java/org/apache/tika/metadata/Icon.java   |  55 +++
 .../org/apache/tika/mime/tika-mimetypes.xml        |  13 +
 .../apache/tika/metadata/metadata-key-fields.json  |   4 +
 .../org/apache/tika/metadata/metadata-keys.json    |   4 +
 .../src/test/resources/2.4.1-no-tesseract.txt      |   2 +-
 .../src/test/resources/2.4.1-tesseract.txt         |   2 +-
 .../java/org/apache/tika/mime/TestMimeTypes.java   |  10 +
 .../src/test/resources/test-documents/testCUR.cur  | Bin 0 -> 5430 bytes
 .../src/test/resources/test-documents/testICO.ico  | Bin 0 -> 7065 bytes
 .../org/apache/tika/parser/image/ICOParser.java    | 334 +++++++++++++++++
 .../org/apache/tika/parser/image/ImageParser.java  |   2 +-
 .../apache/tika/parser/image/ICOParserTest.java    | 407 +++++++++++++++++++++
 .../src/test/resources/test-documents/testCUR.cur  | Bin 0 -> 5430 bytes
 .../src/test/resources/test-documents/testICO.ico  | Bin 0 -> 7065 bytes
 .../resources/test-documents/testICO_bmpOnly.ico   | Bin 0 -> 10806 bytes
 .../apache/tika/config/loader/InferenceLoader.java |   2 +-
 .../tika/config/loader/InferenceLoaderTest.java    |   2 +
 20 files changed, 849 insertions(+), 8 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index b08bbc4361..d7bcc18b01 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -66,6 +66,12 @@ Release 4.2.0 - unreleased
      SERVER/RESOURCE_TIMING) move from INFO to TRACE, and per-fork-start,
      per-connection and plugin lifecycle lines move to DEBUG (TIKA-4949).
 
+   * New ICOParser reads Windows icons and cursors: dimensions and colour
+     depth of the largest image, icon:image-count, icon:images and, for
+     cursors, icon:hotspot-x/-y. Cursors are detected as the new type
+     image/x-win-bitmap (*.cur). Compat: image/vnd.microsoft.icon moves
+     from ImageParser to ICOParser (TIKA-4937).
+
    * tika-core's OSGi manifest no longer requires a Service Loader Mediator or
      providers for Parser, Detector, EncodingDetector, LanguageDetector and
      MetadataFilter; 4.1.0 failed to install on its own in Equinox/p2
diff --git a/docs/modules/ROOT/pages/formats.adoc 
b/docs/modules/ROOT/pages/formats.adoc
index 198a592d01..6595fc5129 100644
--- a/docs/modules/ROOT/pages/formats.adoc
+++ b/docs/modules/ROOT/pages/formats.adoc
@@ -218,9 +218,12 @@ AVIF),
 link:{api}/org/apache/tika/parser/image/JXLParser.html[JXLParser] (JPEG XL),
 link:{api}/org/apache/tika/parser/image/WebPParser.html[WebPParser],
 link:{api}/org/apache/tika/parser/image/PSDParser.html[PSDParser],
-link:{api}/org/apache/tika/parser/image/BPGParser.html[BPGParser] and
-link:{api}/org/apache/tika/parser/image/ICNSParser.html[ICNSParser] extract
-metadata from their respective formats.
+link:{api}/org/apache/tika/parser/image/BPGParser.html[BPGParser],
+link:{api}/org/apache/tika/parser/image/ICNSParser.html[ICNSParser] and
+link:{api}/org/apache/tika/parser/image/ICOParser.html[ICOParser] extract
+metadata from their respective formats; ICOParser reports the size and colour
+depth of the largest image in a Windows icon or cursor, the list of all its
+images and, for cursors, the hotspot.
 
 link:{api}/org/apache/tika/parser/microsoft/WMFParser.html[WMFParser] and
 link:{api}/org/apache/tika/parser/microsoft/EMFParser.html[EMFParser] extract
diff --git a/docs/modules/ROOT/partials/supported-formats.adoc 
b/docs/modules/ROOT/partials/supported-formats.adoc
index e6b296902e..9239661486 100644
--- a/docs/modules/ROOT/partials/supported-formats.adoc
+++ b/docs/modules/ROOT/partials/supported-formats.adoc
@@ -112,6 +112,10 @@
 ** `image/heif-sequence`
 * `org.apache.tika.parser.image.` 
link:{tika-javadoc-url}/org/apache/tika/parser/image/ICNSParser.html[ICNSParser]
 ** `image/icns`
+* `org.apache.tika.parser.image.` 
link:{tika-javadoc-url}/org/apache/tika/parser/image/ICOParser.html[ICOParser]
+** `image/vnd.microsoft.icon`
+** `image/x-icon`
+** `image/x-win-bitmap`
 * `org.apache.tika.parser.image.` 
link:{tika-javadoc-url}/org/apache/tika/parser/image/ImageParser.html[ImageParser]
 ** `image/bmp`
 ** `image/gif`
@@ -119,7 +123,6 @@
 ** `image/jpx`
 ** `image/png`
 ** `image/vnd.wap.wbmp`
-** `image/x-icon`
 ** `image/x-jbig2`
 ** `image/x-ms-bmp`
 ** `image/x-portable-pixmap`
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Icon.java 
b/tika-core/src/main/java/org/apache/tika/metadata/Icon.java
new file mode 100644
index 0000000000..83a3f9a546
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Icon.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.metadata;
+
+/**
+ * Properties of Windows icon (ICO) and cursor (CUR) files, which hold
+ * several images of one motif in different sizes and colour depths.
+ * The dimensions of the largest image go to the {@link TIFF} properties.
+ *
+ * @since Apache Tika 4.2.0
+ */
+public interface Icon {
+
+    String ICON_PREFIX = "icon" + 
TikaCoreProperties.NAMESPACE_PREFIX_DELIMITER;
+
+    /**
+     * Number of images found in the container, the ones {@link #IMAGES}
+     * lists. A damaged container may claim more than it holds.
+     */
+    Property IMAGE_COUNT = Property.internalInteger(ICON_PREFIX + 
"image-count");
+
+    /**
+     * One value per image, in container order: {@code WIDTHxHEIGHT@BITSbpp 
ENCODING},
+     * for example {@code 32x32@32bpp bmp} or {@code 256x256@32bpp png}. An
+     * image whose own header cannot be read is listed with the container's
+     * values for it and the encoding {@code unknown}; a colour depth that is
+     * not known is left out, as in {@code 32x32 unknown}.
+     */
+    Property IMAGES = Property.internalTextBag(ICON_PREFIX + "images");
+
+    /**
+     * Cursors only: the x coordinate of the hotspot of the image whose
+     * dimensions are reported, the pixel that clicks.
+     */
+    Property HOTSPOT_X = Property.internalInteger(ICON_PREFIX + "hotspot-x");
+
+    /**
+     * Cursors only: the y coordinate of the hotspot.
+     */
+    Property HOTSPOT_Y = Property.internalInteger(ICON_PREFIX + "hotspot-y");
+}
diff --git 
a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml 
b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
index 0ccf499954..f1399767c7 100644
--- a/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
+++ b/tika-core/src/main/resources/org/apache/tika/mime/tika-mimetypes.xml
@@ -6900,6 +6900,19 @@
     <glob pattern="*.ico"/>
   </mime-type>
 
+  <mime-type type="image/x-win-bitmap">
+    <acronym>CUR</acronym>
+    <_comment>Windows Cursor</_comment>
+    <tika:link>https://en.wikipedia.org/wiki/ICO_(file_format)</tika:link>
+    <magic priority="50">
+      <!-- reserved 0, type 2, count below 256 -->
+      <match value="\000\000\002\000" type="string" offset="0">
+        <match value="\000" type="string" offset="5"/>
+      </match>
+    </magic>
+    <glob pattern="*.cur"/>
+  </mime-type>
+
   <mime-type type="image/vnd.mix"/>
   <mime-type type="image/vnd.ms-modi">
        <glob pattern="*.mdi"/>
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
index 3f57665d29..f884866b7c 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
@@ -233,6 +233,10 @@
   
{"class":"org.apache.tika.metadata.ISO19115","field":"TRANSFER_OPTIONS_ONLINE_PROFILE","key":"iso19115:transfer-options-online-profile"},
   
{"class":"org.apache.tika.metadata.ISO19115","field":"TRANSFER_OPTIONS_ONLINE_PROTOCOL","key":"iso19115:transfer-options-online-protocol"},
   
{"class":"org.apache.tika.metadata.ISO19115","field":"USE_CONSTRAINTS","key":"iso19115:use-constraints"},
+  
{"class":"org.apache.tika.metadata.Icon","field":"HOTSPOT_X","key":"icon:hotspot-x"},
+  
{"class":"org.apache.tika.metadata.Icon","field":"HOTSPOT_Y","key":"icon:hotspot-y"},
+  
{"class":"org.apache.tika.metadata.Icon","field":"IMAGES","key":"icon:images"},
+  
{"class":"org.apache.tika.metadata.Icon","field":"IMAGE_COUNT","key":"icon:image-count"},
   
{"class":"org.apache.tika.metadata.MAPI","field":"ATTACH_CONTENT_ID","key":"mapi:attach:content-id"},
   
{"class":"org.apache.tika.metadata.MAPI","field":"ATTACH_CONTENT_LOCATION","key":"mapi:attach:content-location"},
   
{"class":"org.apache.tika.metadata.MAPI","field":"ATTACH_DISPLAY_NAME","key":"mapi:attach:display-name"},
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
index 4dc423e946..95d4221cb7 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
@@ -214,6 +214,10 @@
   
{"key":"grobid:tei:xml-source","namespace":"grobid","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-nlp-module"},
   
{"key":"hdf:file-type-description","namespace":"hdf","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-parser-scientific-module"},
   
{"key":"html:scriptSrc","namespace":"html","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"icon:hotspot-x","namespace":"icon","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"icon:hotspot-y","namespace":"icon","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"icon:image-count","namespace":"icon","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"icon:images","namespace":"icon","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
   
{"key":"idml:master-spread-page-count","namespace":"idml","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"idml:spread-page-count","namespace":"idml","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"iso19115:access-constraints","namespace":"iso19115","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
diff --git 
a/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-no-tesseract.txt
 
b/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-no-tesseract.txt
index 572a32b3fc..7f5d55850f 100644
--- 
a/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-no-tesseract.txt
+++ 
b/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-no-tesseract.txt
@@ -303,7 +303,7 @@ image/tiff  class org.apache.tika.parser.image.TiffParser
 image/vnd.adobe.photoshop      class org.apache.tika.parser.image.PSDParser
 image/vnd.dgn; version=8       class org.apache.tika.parser.dgn.DGN8Parser
 image/vnd.dwg  class org.apache.tika.parser.dwg.DWGParser
-image/vnd.microsoft.icon       class org.apache.tika.parser.image.ImageParser
+image/vnd.microsoft.icon       class org.apache.tika.parser.image.ICOParser
 image/vnd.wap.wbmp     class org.apache.tika.parser.image.ImageParser
 image/webp     class org.apache.tika.parser.image.WebPParser
 image/wmf      class org.apache.tika.parser.microsoft.WMFParser
diff --git 
a/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-tesseract.txt
 
b/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-tesseract.txt
index e22a2350ad..09aad99c4d 100644
--- 
a/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-tesseract.txt
+++ 
b/tika-parsers/tika-parsers-extended/tika-parser-scientific-package/src/test/resources/2.4.1-tesseract.txt
@@ -304,7 +304,7 @@ image/tiff  class org.apache.tika.parser.image.TiffParser
 image/vnd.adobe.photoshop      class org.apache.tika.parser.image.PSDParser
 image/vnd.dgn; version=8       class org.apache.tika.parser.dgn.DGN8Parser
 image/vnd.dwg  class org.apache.tika.parser.dwg.DWGParser
-image/vnd.microsoft.icon       class org.apache.tika.parser.image.ImageParser
+image/vnd.microsoft.icon       class org.apache.tika.parser.image.ICOParser
 image/vnd.wap.wbmp     class org.apache.tika.parser.image.ImageParser
 image/webp     class org.apache.tika.parser.image.WebPParser
 image/wmf      class org.apache.tika.parser.microsoft.WMFParser
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
index 8ec7ae208d..dd20419d8d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/mime/TestMimeTypes.java
@@ -533,6 +533,16 @@ public class TestMimeTypes {
         assertTypeByName("image/icns", "testICNS.icns");
     }
 
+    @Test
+    public void testWindowsIconDetection() throws Exception {
+        assertType("image/vnd.microsoft.icon", "testICO.ico");
+        assertTypeByData("image/vnd.microsoft.icon", "testICO.ico");
+        assertTypeByName("image/vnd.microsoft.icon", "x.ico");
+        assertType("image/x-win-bitmap", "testCUR.cur");
+        assertTypeByData("image/x-win-bitmap", "testCUR.cur");
+        assertTypeByName("image/x-win-bitmap", "x.cur");
+    }
+
     @Test
     public void testTiffDetection() throws Exception {
         assertType("image/tiff", "testTIFF.tif");
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testCUR.cur
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testCUR.cur
new file mode 100644
index 0000000000..7779ea7fa2
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testCUR.cur
 differ
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testICO.ico
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testICO.ico
new file mode 100644
index 0000000000..ab59ba78bc
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/resources/test-documents/testICO.ico
 differ
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ICOParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ICOParser.java
new file mode 100644
index 0000000000..3aabf1d73a
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ICOParser.java
@@ -0,0 +1,334 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Set;
+
+import org.apache.commons.io.IOUtils;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.EmbeddedDocumentUtil;
+import org.apache.tika.io.BoundedInputStream;
+import org.apache.tika.io.EndianUtils;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Icon;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TIFF;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.Parser;
+import org.apache.tika.sax.XHTMLContentHandler;
+
+/**
+ * Parser for Windows icon (ICO) and cursor (CUR) files. Reads the ICONDIR
+ * and the header of every image to report the dimensions and colour depth of
+ * the largest image, the list of all images and, for cursors, the hotspot.
+ * The images themselves are not decoded.
+ * <p>
+ * The directory's own width, height and colour fields are unreliable (256 px
+ * is stored as 0, many tools leave the bit count empty), so the values come
+ * from each image's PNG IHDR or BITMAPINFOHEADER and the directory is only
+ * the fallback. Colour depth is reported the TIFF way, bits per sample and
+ * samples per pixel; the per-image list carries the total bits per pixel.
+ * <p>
+ * OS/2 bitmap arrays are detected as the same type. They pass through
+ * without metadata.
+ */
+@TikaComponent
+public class ICOParser implements Parser {
+
+    private static final long serialVersionUID = 4212837190215395123L;
+
+    static final MediaType ICO_TYPE = MediaType.image("vnd.microsoft.icon");
+    static final MediaType ICO_ALIAS = MediaType.image("x-icon");
+    static final MediaType CUR_TYPE = MediaType.image("x-win-bitmap");
+
+    private static final Set<MediaType> SUPPORTED_TYPES = Set.of(ICO_TYPE, 
ICO_ALIAS, CUR_TYPE);
+
+    private static final int TYPE_ICON = 1;
+    private static final int TYPE_CURSOR = 2;
+    private static final int COUNT_OFFSET = 4;
+    private static final int HEADER_SIZE = 6;
+    private static final int ENTRY_SIZE = 16;
+    private static final int BITMAP_INFO_HEADER_SIZE = 40;
+    // the signature, then the first chunk's length and type: an IHDR of 13 
bytes
+    private static final byte[] PNG_HEADER_START =
+            {(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 13, 
'I', 'H', 'D', 'R'};
+    private static final int PNG_IHDR_SIZE = 8 + 8 + 13;
+    // Icons are small; anything bigger is read only this far
+    static final int MAX_FILE_SIZE = 16 * 1024 * 1024;
+    // Far beyond any real icon; a header that claims more is not believed
+    private static final int MAX_DIMENSION = 65535;
+
+    private enum Encoding {
+        PNG, BMP, UNKNOWN
+    }
+
+    @Override
+    public Set<MediaType> getSupportedTypes(ParseContext context) {
+        return SUPPORTED_TYPES;
+    }
+
+    @Override
+    public void parse(TikaInputStream tis, ContentHandler handler, Metadata 
metadata,
+                      ParseContext context) throws IOException, SAXException, 
TikaException {
+        byte[] file = IOUtils.toByteArray(new 
BoundedInputStream(MAX_FILE_SIZE, tis));
+        if (isOs2BitmapArray(file)) {
+            metadata.set(HttpHeaders.CONTENT_TYPE, ICO_TYPE.toString());
+        } else {
+            extractMetadata(file, metadata, context);
+        }
+
+        XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, 
context);
+        xhtml.startDocument();
+        xhtml.endDocument();
+    }
+
+    private static boolean isOs2BitmapArray(byte[] file) {
+        return file.length >= 2 && file[0] == 'B' && file[1] == 'A';
+    }
+
+    private static void extractMetadata(byte[] file, Metadata metadata, 
ParseContext context)
+            throws TikaException {
+        if (file.length < COUNT_OFFSET || EndianUtils.getUShortLE(file, 0) != 
0) {
+            throw new TikaException("Not an ICO or CUR file");
+        }
+        int type = EndianUtils.getUShortLE(file, 2);
+        if (type != TYPE_ICON && type != TYPE_CURSOR) {
+            throw new TikaException("Not an ICO or CUR file: type " + type);
+        }
+        boolean cursor = type == TYPE_CURSOR;
+        metadata.set(HttpHeaders.CONTENT_TYPE, (cursor ? CUR_TYPE : 
ICO_TYPE).toString());
+        if (file.length < HEADER_SIZE) {
+            warn("The header ends before the image count", metadata, context);
+            return;
+        }
+
+        int count = EndianUtils.getUShortLE(file, COUNT_OFFSET);
+        if (count == 0) {
+            metadata.set(Icon.IMAGE_COUNT, 0);
+            warn("The directory lists no images", metadata, context);
+            return;
+        }
+        Image largest = null;
+        int listed = 0;
+        int unreadable = 0;
+        for (int i = 0; i < count; i++) {
+            int entryOffset = HEADER_SIZE + i * ENTRY_SIZE;
+            if (entryOffset + ENTRY_SIZE > file.length) {
+                unreadable += count - i;
+                break;
+            }
+            Image image = Image.read(file, entryOffset, cursor);
+            if (image == null) {
+                unreadable++;
+                continue;
+            }
+            if (image.encoding == Encoding.UNKNOWN) {
+                // still listed with what the directory says, but worth a 
warning
+                unreadable++;
+            }
+            metadata.add(Icon.IMAGES, image.describe());
+            listed++;
+            if (largest == null || image.outranks(largest)) {
+                largest = image;
+            }
+        }
+        // what the header claims beyond that is in the warning
+        metadata.set(Icon.IMAGE_COUNT, listed);
+        if (largest != null) {
+            metadata.set(TIFF.IMAGE_WIDTH, largest.width);
+            metadata.set(TIFF.IMAGE_LENGTH, largest.height);
+            if (largest.bitsPerSample > 0) {
+                metadata.set(TIFF.BITS_PER_SAMPLE, 
Integer.toString(largest.bitsPerSample));
+                metadata.set(TIFF.SAMPLES_PER_PIXEL, largest.samplesPerPixel);
+            }
+            if (cursor) {
+                metadata.set(Icon.HOTSPOT_X, largest.hotspotX);
+                metadata.set(Icon.HOTSPOT_Y, largest.hotspotY);
+            }
+        }
+        if (unreadable > 0) {
+            warn(unreadable + " of " + count + " images lie outside the file 
or have no" +
+                    " readable header", metadata, context);
+        }
+    }
+
+    private static void warn(String message, Metadata metadata, ParseContext 
context) {
+        EmbeddedDocumentUtil.recordException(new TikaException(message), 
metadata, context);
+    }
+
+    private static boolean startsWithPngHeader(byte[] file, int offset) {
+        for (int i = 0; i < PNG_HEADER_START.length; i++) {
+            if (file[offset + i] != PNG_HEADER_START[i]) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * @return the samples per pixel, or 0 for a colour type PNG does not 
define
+     */
+    private static int pngSamplesPerPixel(int colorType) {
+        switch (colorType) {
+            case 0: // greyscale
+            case 3: // palette
+                return 1;
+            case 2: // truecolour
+                return 3;
+            case 4: // greyscale with alpha
+                return 2;
+            case 6: // truecolour with alpha
+                return 4;
+            default:
+                return 0;
+        }
+    }
+
+    private static final class Image {
+        int width;
+        int height;
+        int bitsPerPixel;
+        int bitsPerSample;
+        int samplesPerPixel;
+        int hotspotX;
+        int hotspotY;
+        Encoding encoding = Encoding.UNKNOWN;
+
+        /**
+         * Reads one ICONDIRENTRY and the header of the image it points to.
+         * Without a header that has a usable size, the image keeps the
+         * directory's values and an unknown encoding.
+         *
+         * @return the image, or null if its data lies outside the file
+         */
+        static Image read(byte[] file, int entryOffset, boolean cursor) {
+            Image image = new Image();
+            image.width = directorySize(file[entryOffset]);
+            image.height = directorySize(file[entryOffset + 1]);
+            if (cursor) {
+                // a cursor's directory holds the hotspot where an icon's 
holds planes and bit count
+                image.hotspotX = EndianUtils.getUShortLE(file, entryOffset + 
4);
+                image.hotspotY = EndianUtils.getUShortLE(file, entryOffset + 
6);
+            } else {
+                image.setDepth(EndianUtils.getUShortLE(file, entryOffset + 6));
+            }
+            long size = EndianUtils.getUIntLE(file, entryOffset + 8);
+            long offset = EndianUtils.getUIntLE(file, entryOffset + 12);
+            if (offset < HEADER_SIZE || offset >= file.length) {
+                return null;
+            }
+            int dataOffset = (int) offset;
+            long available = Math.min(size, file.length - offset);
+            if (available >= PNG_IHDR_SIZE && startsWithPngHeader(file, 
dataOffset)) {
+                if (image.trySetSize(EndianUtils.getUIntBE(file, dataOffset + 
16),
+                        EndianUtils.getUIntBE(file, dataOffset + 20))) {
+                    image.encoding = Encoding.PNG;
+                    image.setPngDepth(file[dataOffset + 24] & 0xff, 
file[dataOffset + 25] & 0xff);
+                }
+            } else if (available >= BITMAP_INFO_HEADER_SIZE &&
+                    EndianUtils.getUIntLE(file, dataOffset) == 
BITMAP_INFO_HEADER_SIZE) {
+                // the height covers the XOR bitmap and the AND mask; a 
negative one means top-down
+                long height = Math.abs((long) EndianUtils.getIntLE(file, 
dataOffset + 8)) / 2;
+                if (image.trySetSize(EndianUtils.getIntLE(file, dataOffset + 
4), height)) {
+                    image.encoding = Encoding.BMP;
+                    image.setDepth(EndianUtils.getUShortLE(file, dataOffset + 
14));
+                }
+            }
+            return image;
+        }
+
+        private static int directorySize(byte size) {
+            return size == 0 ? 256 : size & 0xff;
+        }
+
+        /**
+         * @return false, leaving the size as it was, unless both lie between 
1 and
+         *         {@code MAX_DIMENSION}
+         */
+        boolean trySetSize(long width, long height) {
+            if (width <= 0 || width > MAX_DIMENSION || height <= 0 || height > 
MAX_DIMENSION) {
+                return false;
+            }
+            this.width = (int) width;
+            this.height = (int) height;
+            return true;
+        }
+
+        /**
+         * Splits a DIB colour depth into samples: 32 and 24 bit images are
+         * 8 bits per channel, 16 bit ones 5, anything below is palette or 
mono.
+         * A bit count no DIB has leaves the depth as it was.
+         */
+        void setDepth(int bitsPerPixel) {
+            switch (bitsPerPixel) {
+                case 32:
+                case 24:
+                    bitsPerSample = 8;
+                    samplesPerPixel = bitsPerPixel / 8;
+                    break;
+                case 16:
+                    bitsPerSample = 5;
+                    samplesPerPixel = 3;
+                    break;
+                case 8:
+                case 4:
+                case 1:
+                    bitsPerSample = bitsPerPixel;
+                    samplesPerPixel = 1;
+                    break;
+                default:
+                    return;
+            }
+            this.bitsPerPixel = bitsPerPixel;
+        }
+
+        /**
+         * A bit depth or colour type PNG does not define leaves the depth as 
it was.
+         */
+        void setPngDepth(int bitDepth, int colorType) {
+            int samples = pngSamplesPerPixel(colorType);
+            if (samples == 0 || bitDepth > 16 || Integer.bitCount(bitDepth) != 
1) {
+                return;
+            }
+            bitsPerSample = bitDepth;
+            samplesPerPixel = samples;
+            bitsPerPixel = bitDepth * samples;
+        }
+
+        /**
+         * Larger area wins, then the higher colour depth, like Windows' own 
choice.
+         */
+        boolean outranks(Image other) {
+            long area = (long) width * height;
+            long otherArea = (long) other.width * other.height;
+            return area > otherArea || area == otherArea && bitsPerPixel > 
other.bitsPerPixel;
+        }
+
+        String describe() {
+            String depth = bitsPerPixel > 0 ? "@" + bitsPerPixel + "bpp" : "";
+            return width + "x" + height + depth + " " + 
encoding.name().toLowerCase(Locale.ROOT);
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
index 2fe3cf9402..cdcca8e37c 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/main/java/org/apache/tika/parser/image/ImageParser.java
@@ -66,7 +66,7 @@ public class ImageParser extends AbstractImageParser {
         TMP_SUPPORTED = new HashSet<>(
                 Arrays.asList(MAIN_BMP_TYPE, OLD_BMP_TYPE, 
MediaType.image("gif"),
                         MediaType.image("png"), 
MediaType.image("vnd.wap.wbmp"),
-                        MediaType.image("x-icon"), MediaType.image("x-xcf"),
+                        MediaType.image("x-xcf"),
                         MediaType.image("x-jbig2"),
                         // no JPEG 2000 reader (license): owned so OCR still 
reaches them
                         MediaType.image("jp2"), MediaType.image("jpx"),
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ICOParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ICOParserTest.java
new file mode 100644
index 0000000000..d9d8646674
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/java/org/apache/tika/parser/image/ICOParserTest.java
@@ -0,0 +1,407 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.image;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Arrays;
+
+import org.junit.jupiter.api.Test;
+import org.xml.sax.helpers.DefaultHandler;
+
+import org.apache.tika.TikaTest;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.io.EndianUtils;
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Icon;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TIFF;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.DefaultParser;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * testICO.ico holds 16 and 32 px BMP-encoded images plus a 256 px PNG-encoded
+ * one, all 32 bpp; testICO_bmpOnly.ico holds 16 and 48 px BMP images;
+ * testCUR.cur holds 16 px (hotspot 3,2) and 32 px (hotspot 7,5) cursors.
+ * All were generated, not taken from a product.
+ */
+public class ICOParserTest extends TikaTest {
+
+    @Test
+    public void testIconWithPngEntry() throws Exception {
+        Metadata metadata = parse("testICO.ico");
+        assertEquals("image/vnd.microsoft.icon", 
metadata.get(HttpHeaders.CONTENT_TYPE));
+        // the 256 px entry is stored as 0x0 in the directory; the size comes 
from the PNG header
+        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertEquals(256, metadata.getInt(TIFF.IMAGE_LENGTH));
+        // 32 bpp RGBA: 8 bits per sample, 4 samples
+        assertEquals("8", metadata.get(TIFF.BITS_PER_SAMPLE));
+        assertEquals(4, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
+        assertEquals(3, metadata.getInt(Icon.IMAGE_COUNT));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp", 
"256x256@32bpp png"},
+                metadata.getValues(Icon.IMAGES));
+        assertNull(metadata.get(Icon.HOTSPOT_X));
+        
assertNull(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    @Test
+    public void testIconBmpOnly() throws Exception {
+        Metadata metadata = parse("testICO_bmpOnly.ico");
+        assertEquals(48, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertEquals(48, metadata.getInt(TIFF.IMAGE_LENGTH));
+        assertEquals(2, metadata.getInt(Icon.IMAGE_COUNT));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "48x48@32bpp bmp"},
+                metadata.getValues(Icon.IMAGES));
+    }
+
+    @Test
+    public void testCursor() throws Exception {
+        Metadata metadata = parse("testCUR.cur");
+        assertEquals("image/x-win-bitmap", 
metadata.get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertEquals(32, metadata.getInt(TIFF.IMAGE_LENGTH));
+        assertEquals("8", metadata.get(TIFF.BITS_PER_SAMPLE));
+        assertEquals(4, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
+        assertEquals(7, metadata.getInt(Icon.HOTSPOT_X));
+        assertEquals(5, metadata.getInt(Icon.HOTSPOT_Y));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp"},
+                metadata.getValues(Icon.IMAGES));
+    }
+
+    /**
+     * A cursor's directory entry carries the hotspot where an icon's carries
+     * the bit count, so a cursor whose image header is cut has a hotspot but
+     * no colour depth.
+     */
+    @Test
+    public void testCursorWithUnreadableImage() throws Exception {
+        byte[] cur = readTestResource("testCUR.cur");
+        // cut ten bytes into the second image, the 32 px one
+        Metadata metadata = parse(Arrays.copyOf(cur, imageOffset(cur, 1) + 
10));
+        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertNull(metadata.get(TIFF.BITS_PER_SAMPLE));
+        assertEquals(7, metadata.getInt(Icon.HOTSPOT_X));
+        assertEquals(5, metadata.getInt(Icon.HOTSPOT_Y));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32 unknown"},
+                metadata.getValues(Icon.IMAGES));
+        assertContains("1 of 2 images", 
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    @Test
+    public void testAliasRoutesHere() throws Exception {
+        Metadata metadata = new Metadata();
+        metadata.set(HttpHeaders.CONTENT_TYPE, "image/x-icon");
+        try (TikaInputStream tis = 
TikaInputStream.get(readTestResource("testICO.ico"))) {
+            new DefaultParser().parse(tis, new DefaultHandler(), metadata, new 
ParseContext());
+        }
+        assertEquals(3, metadata.getInt(Icon.IMAGE_COUNT));
+        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
+    }
+
+    @Test
+    public void testAutoDetect() throws Exception {
+        Metadata ico = getXML("testICO.ico").metadata;
+        assertEquals("image/vnd.microsoft.icon", 
ico.get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(256, ico.getInt(TIFF.IMAGE_WIDTH));
+        Metadata cur = getXML("testCUR.cur").metadata;
+        assertEquals("image/x-win-bitmap", cur.get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(7, cur.getInt(Icon.HOTSPOT_X));
+    }
+
+    /**
+     * Only two of three images survive the cut; they are reported, the third
+     * is a warning, not a failure.
+     */
+    @Test
+    public void testTruncated() throws Exception {
+        byte[] file = readTestResource("testICO.ico");
+        // the third image, the PNG one, is cut, but its 29 byte header 
survives, and headers
+        // are all this parser reads
+        Metadata metadata = parse(Arrays.copyOf(file, imageOffset(file, 2) + 
100));
+        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertEquals(3, metadata.getInt(Icon.IMAGE_COUNT));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp", 
"256x256@32bpp png"},
+                metadata.getValues(Icon.IMAGES));
+        
assertNull(metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+
+        // the second image's header is cut: its directory entry still says 
32x32, and the
+        // third image lies beyond the end
+        metadata = parse(Arrays.copyOf(file, imageOffset(file, 1) + 10));
+        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp 
unknown"},
+                metadata.getValues(Icon.IMAGES));
+        // the second image's depth comes from the directory, so it is still 
32 bpp
+        assertEquals("8", metadata.get(TIFF.BITS_PER_SAMPLE));
+        // one image cut, one beyond the end; the count is what was found, not 
what the header says
+        assertContains("2 of 3 images", 
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+        assertEquals(2, metadata.getInt(Icon.IMAGE_COUNT));
+
+        // cut inside the directory itself
+        metadata = parse(Arrays.copyOf(file, 30));
+        assertNull(metadata.get(TIFF.IMAGE_WIDTH));
+        assertEquals(0, metadata.getInt(Icon.IMAGE_COUNT));
+        assertContains("3 of 3 images", 
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    /**
+     * A PNG signature alone is no PNG header: the first chunk has to be an
+     * IHDR of 13 bytes.
+     */
+    @Test
+    public void testPngWithoutIhdr() throws Exception {
+        byte[] file = readTestResource("testICO.ico");
+        // the 256 px image's first chunk: its length 8 bytes in, its type 12
+        int png = imageOffset(file, 2);
+        assertFallsBackToDirectory(patch(file, png + 12, 'X', 'X', 'X', 'X'),
+                "256x256@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, png + 8, 0, 0, 0, 12), 
"256x256@32bpp unknown");
+    }
+
+    /**
+     * Of two images of one size the deeper one is reported, wherever it 
stands.
+     */
+    @Test
+    public void testDeeperImageWinsAtEqualSize() throws Exception {
+        byte[] file = readTestResource("testICO_bmpOnly.ico");
+        // the 16 px image's header is made 48 px and 24 bpp, ahead of the 48 
px, 32 bpp one
+        int header = imageOffset(file, 0);
+        byte[] sameSize = patch(patch(patch(file, header + 4, 48), header + 8, 
96), header + 14, 24);
+        Metadata metadata = parse(sameSize);
+        assertArrayEquals(new String[]{"48x48@24bpp bmp", "48x48@32bpp bmp"},
+                metadata.getValues(Icon.IMAGES));
+        assertEquals(4, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
+    }
+
+    /**
+     * An image whose data lies outside the file is not listed at all; the
+     * largest of the others is reported.
+     */
+    @Test
+    public void testDirectoryFallback() throws Exception {
+        byte[] file = readTestResource("testICO.ico");
+        // the third entry's image offset (6 + 2 * 16 + 12) is sent past the 
end of the file
+        Metadata metadata = parse(patch(file, 6 + 2 * 16 + 12, 0xff, 0xff, 0, 
0));
+        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp"},
+                metadata.getValues(Icon.IMAGES));
+        assertContains("1 of 3 images", 
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    /**
+     * OS/2 bitmap arrays share the icon type's magic. They are not read, but
+     * they are not a failure either.
+     */
+    @Test
+    public void testOs2BitmapArray() throws Exception {
+        byte[] bitmapArray = new byte[64];
+        bitmapArray[0] = 'B';
+        bitmapArray[1] = 'A';
+        bitmapArray[2] = 0x28;
+        bitmapArray[6] = 0x2e;
+
+        Metadata metadata = parse(bitmapArray);
+        assertEquals("image/vnd.microsoft.icon", 
metadata.get(HttpHeaders.CONTENT_TYPE));
+        assertNull(metadata.get(Icon.IMAGE_COUNT));
+        assertNull(metadata.get(TIFF.IMAGE_WIDTH));
+
+        Metadata detected = new Metadata();
+        try (TikaInputStream tis = TikaInputStream.get(bitmapArray)) {
+            getXML(tis, AUTO_DETECT_PARSER, detected);
+        }
+        assertEquals("image/vnd.microsoft.icon", 
detected.get(HttpHeaders.CONTENT_TYPE));
+        assertContains(ICOParser.class.getName(),
+                
Arrays.asList(detected.getValues(TikaCoreProperties.TIKA_PARSED_BY)));
+        assertNull(detected.get(TikaCoreProperties.CONTAINER_EXCEPTION));
+    }
+
+    /**
+     * A header whose width or height is zero, negative or beyond any real icon
+     * is unreadable: the directory values stand in.
+     */
+    @Test
+    public void testHostileDimensions() throws Exception {
+        byte[] file = readTestResource("testICO.ico");
+        // the first image is a 16 px BMP, the third a 256 px PNG
+        int bmpWidth = imageOffset(file, 0) + 4;
+        int bmpHeight = imageOffset(file, 0) + 8;
+        int pngWidth = imageOffset(file, 2) + 16;
+        int pngHeight = imageOffset(file, 2) + 20;
+
+        // little endian -1 and Integer.MIN_VALUE
+        assertFallsBackToDirectory(patch(file, bmpWidth, 0xff, 0xff, 0xff, 
0xff),
+                "16x16@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, bmpWidth, 0, 0, 0, 0x80), 
"16x16@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, bmpHeight, 0, 0, 0, 0), 
"16x16@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, bmpHeight, 0, 0, 0, 0x80), 
"16x16@32bpp unknown");
+        // big endian Integer.MIN_VALUE, Integer.MAX_VALUE and one past the 
largest size believed
+        assertFallsBackToDirectory(patch(file, pngWidth, 0x80, 0, 0, 0), 
"256x256@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, pngHeight, 0x80, 0, 0, 0), 
"256x256@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, pngHeight, 0, 0, 0, 0), 
"256x256@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, pngWidth, 0x7f, 0xff, 0xff, 
0xff),
+                "256x256@32bpp unknown");
+        assertFallsBackToDirectory(patch(file, pngWidth, 0, 1, 0, 0), 
"256x256@32bpp unknown");
+        // two negative dimensions have a positive product, which must not win
+        assertFallsBackToDirectory(
+                patch(patch(file, pngWidth, 0x80, 0, 0, 0), pngHeight, 0x80, 
0, 0, 0),
+                "256x256@32bpp unknown");
+    }
+
+    /**
+     * The fixtures are all 32 bpp; the other depths are patched into their 
headers.
+     */
+    @Test
+    public void testColourDepths() throws Exception {
+        byte[] bmpOnly = readTestResource("testICO_bmpOnly.ico");
+        // the 48 px image is the second entry; a BITMAPINFOHEADER holds the 
bit count at 14
+        int bitCount = imageOffset(bmpOnly, 1) + 14;
+        assertDepth(patch(bmpOnly, bitCount, 24, 0), "48x48@24bpp bmp", "8", 
3);
+        assertDepth(patch(bmpOnly, bitCount, 16, 0), "48x48@16bpp bmp", "5", 
3);
+        assertDepth(patch(bmpOnly, bitCount, 8, 0), "48x48@8bpp bmp", "8", 1);
+        assertDepth(patch(bmpOnly, bitCount, 1, 0), "48x48@1bpp bmp", "1", 1);
+
+        byte[] withPng = readTestResource("testICO.ico");
+        // an IHDR holds the bit depth at 24 and the colour type at 25
+        int bitDepth = imageOffset(withPng, 2) + 24;
+        assertDepth(patch(withPng, bitDepth, 8, 2), "256x256@24bpp png", "8", 
3);
+        assertDepth(patch(withPng, bitDepth, 8, 4), "256x256@16bpp png", "8", 
2);
+        assertDepth(patch(withPng, bitDepth, 8, 3), "256x256@8bpp png", "8", 
1);
+        assertDepth(patch(withPng, bitDepth, 16, 6), "256x256@64bpp png", 
"16", 4);
+    }
+
+    /**
+     * A depth no DIB or PNG can have is ignored; the directory's 32 bpp 
stands in.
+     */
+    @Test
+    public void testImplausibleColourDepths() throws Exception {
+        byte[] bmpOnly = readTestResource("testICO_bmpOnly.ico");
+        int bitCount = imageOffset(bmpOnly, 1) + 14;
+        assertDepth(patch(bmpOnly, bitCount, 0xff, 0xff), "48x48@32bpp bmp", 
"8", 4);
+        assertDepth(patch(bmpOnly, bitCount, 7, 0), "48x48@32bpp bmp", "8", 4);
+
+        byte[] withPng = readTestResource("testICO.ico");
+        int bitDepth = imageOffset(withPng, 2) + 24;
+        assertDepth(patch(withPng, bitDepth, 0xff, 6), "256x256@32bpp png", 
"8", 4);
+        assertDepth(patch(withPng, bitDepth, 8, 5), "256x256@32bpp png", "8", 
4);
+
+        // the directory's own bit count is no more trusted: the 32 px image 
is cut and has no other
+        byte[] cut = Arrays.copyOf(patch(withPng, 6 + 16 + 6, 0xff, 0xff), 
imageOffset(withPng, 1) + 10);
+        assertContains("32x32 unknown", 
Arrays.asList(parse(cut).getValues(Icon.IMAGES)));
+    }
+
+    private static void assertDepth(byte[] file, String expectedImage, String 
bitsPerSample,
+                                    int samplesPerPixel) throws Exception {
+        Metadata metadata = parse(file);
+        assertContains(expectedImage, 
Arrays.asList(metadata.getValues(Icon.IMAGES)));
+        assertEquals(bitsPerSample, metadata.get(TIFF.BITS_PER_SAMPLE));
+        assertEquals(samplesPerPixel, metadata.getInt(TIFF.SAMPLES_PER_PIXEL));
+    }
+
+    @Test
+    public void testEmptyDirectory() throws Exception {
+        Metadata metadata = parse(new byte[]{0, 0, 1, 0, 0, 0});
+        assertEquals("image/vnd.microsoft.icon", 
metadata.get(HttpHeaders.CONTENT_TYPE));
+        assertEquals(0, metadata.getInt(Icon.IMAGE_COUNT));
+        assertContains("no images", 
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    @Test
+    public void testHeaderCutBeforeImageCount() throws Exception {
+        for (byte[] header : new byte[][]{{0, 0, 2, 0}, {0, 0, 2, 0, 1}}) {
+            Metadata metadata = parse(header);
+            assertEquals("image/x-win-bitmap", 
metadata.get(HttpHeaders.CONTENT_TYPE));
+            assertNull(metadata.get(Icon.IMAGE_COUNT));
+            assertContains("image count",
+                    
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+        }
+    }
+
+    /**
+     * An image whose header lies beyond the size limit is as unreadable as one
+     * beyond the end of the file.
+     */
+    @Test
+    public void testSizeLimit() throws Exception {
+        byte[] file = readTestResource("testICO.ico");
+        int limit = ICOParser.MAX_FILE_SIZE;
+        byte[] large = Arrays.copyOf(file, limit + 64);
+        // the 256 px image's PNG header, copied to just beyond the limit, and 
its entry's offset
+        System.arraycopy(file, imageOffset(file, 2), large, limit, 29);
+        int offset = 6 + 2 * 16 + 12;
+        large[offset] = (byte) limit;
+        large[offset + 1] = (byte) (limit >> 8);
+        large[offset + 2] = (byte) (limit >> 16);
+        large[offset + 3] = (byte) (limit >> 24);
+
+        Metadata metadata = parse(large);
+        assertEquals(32, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertArrayEquals(new String[]{"16x16@32bpp bmp", "32x32@32bpp bmp"},
+                metadata.getValues(Icon.IMAGES));
+        assertContains("1 of 3 images", 
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    /**
+     * @return where the image with the given index starts, as its directory 
entry says
+     */
+    private static int imageOffset(byte[] file, int index) {
+        return (int) EndianUtils.getUIntLE(file, 6 + index * 16 + 12);
+    }
+
+    private static byte[] patch(byte[] file, int offset, int... bytes) {
+        byte[] patched = file.clone();
+        for (int i = 0; i < bytes.length; i++) {
+            patched[offset + i] = (byte) bytes[i];
+        }
+        return patched;
+    }
+
+    private static void assertFallsBackToDirectory(byte[] file, String 
expected) throws Exception {
+        Metadata metadata = parse(file);
+        assertContains(expected, 
Arrays.asList(metadata.getValues(Icon.IMAGES)));
+        assertEquals(256, metadata.getInt(TIFF.IMAGE_WIDTH));
+        assertEquals(256, metadata.getInt(TIFF.IMAGE_LENGTH));
+        assertContains("1 of 3 images", 
metadata.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    @Test
+    public void testNotAnIcon() {
+        assertThrows(TikaException.class, () -> parse(new byte[]{0, 0, 3, 0, 
1, 0}));
+        assertThrows(TikaException.class, () -> parse(new byte[]{1, 2, 3}));
+    }
+
+    private Metadata parse(String name) throws Exception {
+        return parse(readTestResource(name));
+    }
+
+    private static Metadata parse(byte[] file) throws Exception {
+        Metadata metadata = new Metadata();
+        try (TikaInputStream tis = TikaInputStream.get(file)) {
+            new ICOParser().parse(tis, new DefaultHandler(), metadata, new 
ParseContext());
+        }
+        return metadata;
+    }
+
+    private byte[] readTestResource(String name) throws Exception {
+        try (TikaInputStream tis = getResourceAsStream("/test-documents/" + 
name)) {
+            return tis.readAllBytes();
+        }
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testCUR.cur
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testCUR.cur
new file mode 100644
index 0000000000..7779ea7fa2
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testCUR.cur
 differ
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testICO.ico
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testICO.ico
new file mode 100644
index 0000000000..ab59ba78bc
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testICO.ico
 differ
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testICO_bmpOnly.ico
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testICO_bmpOnly.ico
new file mode 100644
index 0000000000..5baef96f69
Binary files /dev/null and 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-image-module/src/test/resources/test-documents/testICO_bmpOnly.ico
 differ
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
index cc4a117966..cdf0870d1d 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
@@ -59,7 +59,7 @@ class InferenceLoader implements 
ComponentLoader<InferenceDispatcher> {
     private static final List<String> NON_RASTER = List.of("image/svg+xml", 
"image/vnd.dwg",
             "image/vnd.dxf", "image/x-emf", "image/x-wmf", "image/wmf", 
"image/emf",
             "image/vnd.adobe.photoshop", "image/x-photoshop", 
"image/vnd.microsoft.icon",
-            "image/x-icon");
+            "image/x-icon", "image/x-win-bitmap");
 
     @Override
     public InferenceDispatcher load(TikaJsonConfig config, LoaderContext 
context)
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
index 233f596a4b..82a9dc0cf9 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
@@ -92,6 +92,8 @@ public class InferenceLoaderTest {
         assertTrue(second.binding().accepts(InputKind.IMAGES, 
MediaType.image("png")));
         assertFalse(second.binding().accepts(InputKind.IMAGES, 
MediaType.image("svg+xml")),
                 "without an include list, non-raster image types are excluded 
by default");
+        assertFalse(second.binding().accepts(InputKind.IMAGES, 
MediaType.image("x-win-bitmap")),
+                "a cursor holds images, it is not one");
 
         AutoDetectParser parser = (AutoDetectParser) 
loader.loadAutoDetectParser();
         assertEquals(List.of(dispatcher), parser.getParseHooks().getHooks(),

Reply via email to