You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/02/19 10:15:12 UTC
(sedona) 02/03: Address COG writer code review findings
This is an automated email from the ASF dual-hosted git repository.
jiayu pushed a commit to branch feature/cog-writer
in repository https://gitbox.apache.org/repos/asf/sedona.git
commit 6889c286c2fe994647df14ea73fffd35476cb033
Author: Jia Yu <ji...@apache.org>
AuthorDate: Thu Feb 19 00:50:30 2026 -0800
Address COG writer code review findings
- Remove unused ReferencedEnvelope variable (Finding 5)
- Remove unused isOverview parameter from writeAsTiledGeoTiff (Finding 2)
- Add input validation: compressionQuality range, tileSize positive & power-of-2 (Finding 3)
- Add parser bounds checks: IFD offset, entries length, overflow/image data ranges (Finding 4)
- Inject NewSubfileType tag (254=1) into overview IFDs when missing (Finding 1)
- Add 4 new tests: NewSubfileType presence, invalid inputs, malformed TIFF rejection,
forward-pointing tile offsets
---
.../sedona/common/raster/cog/CogAssembler.java | 88 +++++++++-
.../apache/sedona/common/raster/cog/CogWriter.java | 26 +--
.../sedona/common/raster/cog/TiffIfdParser.java | 47 +++++-
.../sedona/common/raster/cog/CogWriterTest.java | 177 +++++++++++++++++++++
4 files changed, 316 insertions(+), 22 deletions(-)
diff --git a/common/src/main/java/org/apache/sedona/common/raster/cog/CogAssembler.java b/common/src/main/java/org/apache/sedona/common/raster/cog/CogAssembler.java
index 579f9be9d4..f6879ae9ea 100644
--- a/common/src/main/java/org/apache/sedona/common/raster/cog/CogAssembler.java
+++ b/common/src/main/java/org/apache/sedona/common/raster/cog/CogAssembler.java
@@ -75,11 +75,21 @@ public class CogAssembler {
ByteOrder byteOrder = parsedTiffs.get(0).byteOrder;
int ifdCount = parsedTiffs.size();
+ // Determine which overview IFDs need NewSubfileType injection
+ boolean[] needsNewSubfileType = new boolean[ifdCount];
+ for (int i = 1; i < ifdCount; i++) {
+ needsNewSubfileType[i] = !parsedTiffs.get(i).hasNewSubfileType;
+ }
+
// Phase 1: Compute sizes of all IFD regions (IFD entries + overflow data)
+ // If we need to inject NewSubfileType, the IFD grows by 12 bytes (one tag entry)
int[] ifdRegionSizes = new int[ifdCount];
+ int[] effectiveTagCounts = new int[ifdCount];
for (int i = 0; i < ifdCount; i++) {
TiffIfdParser.ParsedTiff pt = parsedTiffs.get(i);
- ifdRegionSizes[i] = pt.getIfdAndOverflowSize();
+ int extraBytes = needsNewSubfileType[i] ? 12 : 0;
+ effectiveTagCounts[i] = pt.tagCount + (needsNewSubfileType[i] ? 1 : 0);
+ ifdRegionSizes[i] = pt.getIfdAndOverflowSize() + extraBytes;
}
// Phase 2: Compute absolute offsets for each IFD and its image data.
@@ -121,20 +131,30 @@ public class CogAssembler {
// Write each IFD + its overflow data
for (int i = 0; i < ifdCount; i++) {
TiffIfdParser.ParsedTiff pt = parsedTiffs.get(i);
+ boolean isOverview = i > 0;
int ifdStart = ifdAbsoluteOffsets[i];
int nextIfdOffset = (i + 1 < ifdCount) ? ifdAbsoluteOffsets[i + 1] : 0;
+ int tagCountForIfd = effectiveTagCounts[i];
// Compute where this IFD's overflow data will be in the output
- int overflowStartInOutput = ifdStart + pt.getIfdSize();
+ // Account for possible extra 12 bytes from injected tag
+ int overflowStartInOutput = ifdStart + 2 + tagCountForIfd * 12 + 4;
// Patch the IFD entries:
// - Rebase overflow pointers from original file offsets to new output offsets
// - Rewrite TileOffsets/StripOffsets to point to the new image data location
+ // - Inject NewSubfileType=1 for overview IFDs if missing
byte[] patchedEntries =
- patchIfdEntries(pt, overflowStartInOutput, imageDataAbsoluteOffsets[i], i > 0, byteOrder);
+ patchIfdEntries(
+ pt,
+ overflowStartInOutput,
+ imageDataAbsoluteOffsets[i],
+ isOverview,
+ needsNewSubfileType[i],
+ byteOrder);
- // Write: tag count (2 bytes) + entries (tagCount*12) + next IFD offset (4 bytes)
- writeShort(dos, byteOrder, pt.tagCount);
+ // Write: tag count (2 bytes) + entries (tagCountForIfd*12) + next IFD offset (4 bytes)
+ writeShort(dos, byteOrder, tagCountForIfd);
dos.write(patchedEntries);
writeInt(dos, byteOrder, nextIfdOffset);
@@ -157,7 +177,7 @@ public class CogAssembler {
* <ol>
* <li>Overflow data pointers (rebase from original file offset to new output offset)
* <li>TileOffsets/StripOffsets values (point to new image data location)
- * <li>Inject NewSubfileType=1 for overview IFDs (if not already present)
+ * <li>Set or inject NewSubfileType=1 for overview IFDs
* </ol>
*/
private static byte[] patchIfdEntries(
@@ -165,6 +185,7 @@ public class CogAssembler {
int newOverflowStart,
int newImageDataStart,
boolean isOverview,
+ boolean injectNewSubfileType,
ByteOrder byteOrder) {
byte[] entries = pt.ifdEntries.clone();
@@ -179,7 +200,7 @@ public class CogAssembler {
int count = buf.getInt(offset + 4);
int valueSize = count * getFieldTypeSize(fieldType);
- // Handle NewSubfileType tag for overviews
+ // Handle NewSubfileType tag for overviews (when already present)
if (tag == TiffIfdParser.TAG_NEW_SUBFILE_TYPE && isOverview) {
buf.putInt(offset + 8, REDUCED_IMAGE);
continue;
@@ -216,9 +237,62 @@ public class CogAssembler {
}
}
+ // If we need to inject NewSubfileType, insert a 12-byte entry in sorted tag order
+ if (injectNewSubfileType) {
+ return insertNewSubfileTypeTag(entries, pt.tagCount, byteOrder);
+ }
+
return entries;
}
+ /**
+ * Insert a NewSubfileType tag entry (tag 254) into a sorted IFD entry array. The new entry is
+ * placed at the correct position to maintain tag sort order, and existing entries after it are
+ * shifted right by 12 bytes.
+ *
+ * @param entries The original IFD entries (tagCount * 12 bytes)
+ * @param tagCount The original number of tags
+ * @param byteOrder The byte order for writing the new entry
+ * @return A new byte array that is 12 bytes longer, with the NewSubfileType entry inserted
+ */
+ private static byte[] insertNewSubfileTypeTag(byte[] entries, int tagCount, ByteOrder byteOrder) {
+ // NewSubfileType = tag 254, very low, typically goes near the start
+ byte[] newEntries = new byte[entries.length + 12];
+ ByteBuffer srcBuf = ByteBuffer.wrap(entries).order(byteOrder);
+
+ // Find insertion point: first tag with code > 254
+ int insertIdx = tagCount; // default: append at end
+ for (int i = 0; i < tagCount; i++) {
+ int tag = srcBuf.getShort(i * 12) & 0xFFFF;
+ if (tag > TiffIfdParser.TAG_NEW_SUBFILE_TYPE) {
+ insertIdx = i;
+ break;
+ }
+ }
+
+ // Copy entries before insertion point
+ int beforeBytes = insertIdx * 12;
+ if (beforeBytes > 0) {
+ System.arraycopy(entries, 0, newEntries, 0, beforeBytes);
+ }
+
+ // Write the NewSubfileType entry at insertion point
+ ByteBuffer newBuf = ByteBuffer.wrap(newEntries).order(byteOrder);
+ int insertOffset = insertIdx * 12;
+ newBuf.putShort(insertOffset, (short) TiffIfdParser.TAG_NEW_SUBFILE_TYPE); // tag = 254
+ newBuf.putShort(insertOffset + 2, (short) 4); // type = LONG
+ newBuf.putInt(insertOffset + 4, 1); // count = 1
+ newBuf.putInt(insertOffset + 8, REDUCED_IMAGE); // value = 1
+
+ // Copy entries after insertion point
+ int afterBytes = entries.length - beforeBytes;
+ if (afterBytes > 0) {
+ System.arraycopy(entries, beforeBytes, newEntries, beforeBytes + 12, afterBytes);
+ }
+
+ return newEntries;
+ }
+
/** Write a 16-bit value respecting byte order */
private static void writeShort(DataOutputStream dos, ByteOrder order, int value)
throws IOException {
diff --git a/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java b/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java
index 5a0bad8c26..4adadedd43 100644
--- a/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java
+++ b/common/src/main/java/org/apache/sedona/common/raster/cog/CogWriter.java
@@ -38,7 +38,6 @@ import org.geotools.coverage.grid.io.AbstractGridFormat;
import org.geotools.coverage.processing.Operations;
import org.geotools.gce.geotiff.GeoTiffWriteParams;
import org.geotools.gce.geotiff.GeoTiffWriter;
-import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.referencing.operation.transform.AffineTransform2D;
/**
@@ -87,6 +86,16 @@ public class CogWriter {
if (compressionQuality < 0) {
compressionQuality = 0.2;
}
+ if (compressionQuality > 1.0) {
+ throw new IllegalArgumentException(
+ "compressionQuality must be between 0.0 and 1.0, got: " + compressionQuality);
+ }
+ if (tileSize <= 0) {
+ throw new IllegalArgumentException("tileSize must be positive, got: " + tileSize);
+ }
+ if ((tileSize & (tileSize - 1)) != 0) {
+ throw new IllegalArgumentException("tileSize must be a power of 2, got: " + tileSize);
+ }
RenderedImage image = raster.getRenderedImage();
int cols = image.getWidth();
@@ -104,11 +113,9 @@ public class CogWriter {
// Step 3: Write each as a tiled GeoTIFF byte array
List<byte[]> tiffBytes = new ArrayList<>();
- tiffBytes.add(
- writeAsTiledGeoTiff(raster, compressionType, compressionQuality, tileSize, false));
+ tiffBytes.add(writeAsTiledGeoTiff(raster, compressionType, compressionQuality, tileSize));
for (GridCoverage2D overview : overviews) {
- tiffBytes.add(
- writeAsTiledGeoTiff(overview, compressionType, compressionQuality, tileSize, true));
+ tiffBytes.add(writeAsTiledGeoTiff(overview, compressionType, compressionQuality, tileSize));
}
// Step 4: Parse each TIFF's IFD structure
@@ -187,7 +194,6 @@ public class CogWriter {
int newHeight = (int) Math.ceil((double) image.getHeight() / decimationFactor);
// Use GeoTools Operations.DEFAULT.resample to downsample
- ReferencedEnvelope envelope = raster.getEnvelope2D();
CoordinateReferenceSystem crs = raster.getCoordinateReferenceSystem2D();
AffineTransform2D originalTransform =
@@ -223,17 +229,11 @@ public class CogWriter {
* @param compressionType Compression type
* @param compressionQuality Quality 0.0 to 1.0
* @param tileSize Tile dimensions in pixels
- * @param isOverview If true, sets NewSubfileType=1 (ReducedImage) — note: GeoTools may not
- * support this directly, in which case the CogAssembler handles it during assembly.
* @return Tiled GeoTIFF as byte array
* @throws IOException if writing fails
*/
private static byte[] writeAsTiledGeoTiff(
- GridCoverage2D raster,
- String compressionType,
- double compressionQuality,
- int tileSize,
- boolean isOverview)
+ GridCoverage2D raster, String compressionType, double compressionQuality, int tileSize)
throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
diff --git a/common/src/main/java/org/apache/sedona/common/raster/cog/TiffIfdParser.java b/common/src/main/java/org/apache/sedona/common/raster/cog/TiffIfdParser.java
index 7c0d31cd1e..60e48e0051 100644
--- a/common/src/main/java/org/apache/sedona/common/raster/cog/TiffIfdParser.java
+++ b/common/src/main/java/org/apache/sedona/common/raster/cog/TiffIfdParser.java
@@ -103,6 +103,9 @@ public class TiffIfdParser {
/** Byte counts of each tile/strip */
public final int[] segmentByteCounts;
+ /** Whether the IFD contains a NewSubfileType tag */
+ public final boolean hasNewSubfileType;
+
/** The total size of the IFD region: 2 (count) + tagCount*12 + 4 (next pointer) */
public int getIfdSize() {
return 2 + tagCount * 12 + 4;
@@ -122,7 +125,8 @@ public class TiffIfdParser {
int overflowDataStart,
byte[] imageData,
int[] segmentOffsets,
- int[] segmentByteCounts) {
+ int[] segmentByteCounts,
+ boolean hasNewSubfileType) {
this.byteOrder = byteOrder;
this.ifdOffset = ifdOffset;
this.tagCount = tagCount;
@@ -132,6 +136,7 @@ public class TiffIfdParser {
this.imageData = imageData;
this.segmentOffsets = segmentOffsets;
this.segmentByteCounts = segmentByteCounts;
+ this.hasNewSubfileType = hasNewSubfileType;
}
}
@@ -168,6 +173,10 @@ public class TiffIfdParser {
// Read first IFD offset
int ifdOffset = buf.getInt(4);
+ if (ifdOffset < 8 || ifdOffset >= tiffBytes.length - 2) {
+ throw new IllegalArgumentException(
+ "IFD offset out of range: " + ifdOffset + " (file size: " + tiffBytes.length + ")");
+ }
// Read number of directory entries
int tagCount = buf.getShort(ifdOffset) & 0xFFFF;
@@ -175,6 +184,15 @@ public class TiffIfdParser {
// Read all IFD entries (12 bytes each)
int entriesStart = ifdOffset + 2;
int entriesLen = tagCount * 12;
+ if (entriesStart + entriesLen > tiffBytes.length) {
+ throw new IllegalArgumentException(
+ "IFD entries extend beyond file: entriesStart="
+ + entriesStart
+ + " entriesLen="
+ + entriesLen
+ + " fileSize="
+ + tiffBytes.length);
+ }
byte[] ifdEntries = new byte[entriesLen];
System.arraycopy(tiffBytes, entriesStart, ifdEntries, 0, entriesLen);
@@ -182,6 +200,7 @@ public class TiffIfdParser {
int offsetsTag = -1;
int byteCountsTag = -1;
int segmentCount = 0;
+ boolean hasNewSubfileType = false;
// Also track the overflow data region
int overflowStart = Integer.MAX_VALUE;
@@ -200,11 +219,24 @@ public class TiffIfdParser {
segmentCount = count;
} else if (tag == TAG_TILE_BYTE_COUNTS || tag == TAG_STRIP_BYTE_COUNTS) {
byteCountsTag = tag;
+ } else if (tag == TAG_NEW_SUBFILE_TYPE) {
+ hasNewSubfileType = true;
}
// Track overflow data region (values > 4 bytes stored outside IFD entries)
if (valueSize > 4) {
int valOffset = buf.getInt(entryOffset + 8);
+ if (valOffset < 0 || valOffset + valueSize > tiffBytes.length) {
+ throw new IllegalArgumentException(
+ "Overflow data for tag "
+ + tag
+ + " out of range: offset="
+ + valOffset
+ + " size="
+ + valueSize
+ + " fileSize="
+ + tiffBytes.length);
+ }
overflowStart = Math.min(overflowStart, valOffset);
overflowEnd = Math.max(overflowEnd, valOffset + valueSize);
}
@@ -239,6 +271,16 @@ public class TiffIfdParser {
imageDataEnd = Math.max(imageDataEnd, segmentOffsets[i] + segmentByteCounts[i]);
}
+ if (imageDataStart < 0 || imageDataEnd > tiffBytes.length) {
+ throw new IllegalArgumentException(
+ "Image data region out of range: start="
+ + imageDataStart
+ + " end="
+ + imageDataEnd
+ + " fileSize="
+ + tiffBytes.length);
+ }
+
// Extract image data
byte[] imageData = new byte[imageDataEnd - imageDataStart];
System.arraycopy(tiffBytes, imageDataStart, imageData, 0, imageData.length);
@@ -258,7 +300,8 @@ public class TiffIfdParser {
overflowDataStart,
imageData,
relativeOffsets,
- segmentByteCounts);
+ segmentByteCounts,
+ hasNewSubfileType);
}
/**
diff --git a/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java b/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java
index 45c7be3006..dfbbd07d0e 100644
--- a/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java
+++ b/common/src/test/java/org/apache/sedona/common/raster/cog/CogWriterTest.java
@@ -242,4 +242,181 @@ public class CogWriterTest {
assertTrue(parsed.imageData.length > 0);
assertTrue(parsed.ifdEntries.length == parsed.tagCount * 12);
}
+
+ @Test
+ public void testOverviewIfdHasNewSubfileType() throws IOException {
+ // Create a 512x512 raster that will have at least one overview
+ double[] bandValues = new double[512 * 512];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 3) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 512, 512, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ byte[] cogBytes = RasterOutputs.asCloudOptimizedGeoTiff(raster);
+ ByteOrder byteOrder = (cogBytes[0] == 'I') ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN;
+ ByteBuffer buf = ByteBuffer.wrap(cogBytes).order(byteOrder);
+
+ // Navigate to second IFD (first overview)
+ int firstIfdOffset = buf.getInt(4);
+ int tagCount0 = buf.getShort(firstIfdOffset) & 0xFFFF;
+ int nextIfdPointerPos = firstIfdOffset + 2 + tagCount0 * 12;
+ int secondIfdOffset = buf.getInt(nextIfdPointerPos);
+ assertTrue("Should have at least one overview IFD", secondIfdOffset > 0);
+
+ // Scan second IFD for NewSubfileType tag (254)
+ int tagCount1 = buf.getShort(secondIfdOffset) & 0xFFFF;
+ boolean foundNewSubfileType = false;
+ int newSubfileTypeValue = -1;
+ for (int i = 0; i < tagCount1; i++) {
+ int entryOffset = secondIfdOffset + 2 + i * 12;
+ int tag = buf.getShort(entryOffset) & 0xFFFF;
+ if (tag == 254) {
+ foundNewSubfileType = true;
+ newSubfileTypeValue = buf.getInt(entryOffset + 8);
+ break;
+ }
+ }
+ assertTrue("Overview IFD must contain NewSubfileType tag (254)", foundNewSubfileType);
+ assertEquals("NewSubfileType must be 1 (ReducedImage)", 1, newSubfileTypeValue);
+ }
+
+ @Test
+ public void testInvalidInputParameters() {
+ double[] bandValues = new double[50 * 50];
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 50, 50, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ // compressionQuality > 1
+ try {
+ CogWriter.write(raster, "Deflate", 1.5, 256);
+ fail("Expected IllegalArgumentException for compressionQuality > 1");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("compressionQuality"));
+ } catch (IOException e) {
+ fail("Expected IllegalArgumentException, not IOException");
+ }
+
+ // tileSize <= 0
+ try {
+ CogWriter.write(raster, "Deflate", 0.5, 0);
+ fail("Expected IllegalArgumentException for tileSize <= 0");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("tileSize"));
+ } catch (IOException e) {
+ fail("Expected IllegalArgumentException, not IOException");
+ }
+
+ // tileSize not power of 2
+ try {
+ CogWriter.write(raster, "Deflate", 0.5, 100);
+ fail("Expected IllegalArgumentException for non-power-of-2 tileSize");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("power of 2"));
+ } catch (IOException e) {
+ fail("Expected IllegalArgumentException, not IOException");
+ }
+ }
+
+ @Test
+ public void testParserRejectsMalformedTiff() {
+ // Too short
+ try {
+ TiffIfdParser.parse(new byte[] {0, 0, 0});
+ fail("Expected IllegalArgumentException for short input");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("too short"));
+ }
+
+ // Invalid byte order marker
+ try {
+ TiffIfdParser.parse(new byte[] {'X', 'X', 0, 42, 0, 0, 0, 8});
+ fail("Expected IllegalArgumentException for invalid byte order");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("byte order"));
+ }
+
+ // Valid header but IFD offset points beyond file
+ byte[] badOffset = new byte[16];
+ badOffset[0] = 'I';
+ badOffset[1] = 'I';
+ badOffset[2] = 42;
+ badOffset[3] = 0;
+ // IFD offset = 9999 (way beyond file)
+ ByteBuffer b = ByteBuffer.wrap(badOffset).order(ByteOrder.LITTLE_ENDIAN);
+ b.putInt(4, 9999);
+ try {
+ TiffIfdParser.parse(badOffset);
+ fail("Expected IllegalArgumentException for out-of-range IFD offset");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("out of range"));
+ }
+ }
+
+ @Test
+ public void testCogTileOffsetsAreForwardPointing() throws IOException {
+ // Create a raster with overviews
+ double[] bandValues = new double[512 * 512];
+ for (int i = 0; i < bandValues.length; i++) {
+ bandValues[i] = (i * 11) % 256;
+ }
+ GridCoverage2D raster =
+ RasterConstructors.makeNonEmptyRaster(
+ 1, "d", 512, 512, 0, 0, 1, -1, 0, 0, 4326, new double[][] {bandValues});
+
+ byte[] cogBytes = RasterOutputs.asCloudOptimizedGeoTiff(raster);
+ ByteOrder byteOrder = (cogBytes[0] == 'I') ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN;
+ ByteBuffer buf = ByteBuffer.wrap(cogBytes).order(byteOrder);
+
+ // Walk all IFDs and verify TileOffsets/StripOffsets point within file bounds
+ int ifdOffset = buf.getInt(4);
+ int ifdIndex = 0;
+ int lastIfdEnd = 0;
+
+ while (ifdOffset != 0) {
+ int tagCount = buf.getShort(ifdOffset) & 0xFFFF;
+ int ifdEnd = ifdOffset + 2 + tagCount * 12 + 4;
+ lastIfdEnd = Math.max(lastIfdEnd, ifdEnd);
+
+ for (int i = 0; i < tagCount; i++) {
+ int entryOffset = ifdOffset + 2 + i * 12;
+ int tag = buf.getShort(entryOffset) & 0xFFFF;
+ int fieldType = buf.getShort(entryOffset + 2) & 0xFFFF;
+ int count = buf.getInt(entryOffset + 4);
+
+ // Check TileOffsets (324) or StripOffsets (273)
+ if (tag == 324 || tag == 273) {
+ if (count == 1) {
+ int offset = buf.getInt(entryOffset + 8);
+ assertTrue(
+ "IFD " + ifdIndex + ": TileOffset " + offset + " must be within file",
+ offset >= 0 && offset < cogBytes.length);
+ } else {
+ // Offsets stored in overflow area
+ int arrayOffset = buf.getInt(entryOffset + 8);
+ for (int j = 0; j < count; j++) {
+ int tileOffset = buf.getInt(arrayOffset + j * 4);
+ assertTrue(
+ "IFD " + ifdIndex + " tile " + j + ": offset " + tileOffset + " out of range",
+ tileOffset >= 0 && tileOffset < cogBytes.length);
+ }
+ }
+ }
+ }
+
+ // Read next IFD offset
+ int nextIfdPointerPos = ifdOffset + 2 + tagCount * 12;
+ ifdOffset = buf.getInt(nextIfdPointerPos);
+ ifdIndex++;
+ }
+
+ // Verify we found at least 2 IFDs (full-res + overview)
+ assertTrue("Expected at least 2 IFDs, found " + ifdIndex, ifdIndex >= 2);
+
+ // Verify all IFDs are before image data (forward-pointing)
+ // The last IFD end should be well before the end of the file
+ assertTrue("IFD region should be at start of file", lastIfdEnd < cogBytes.length / 2);
+ }
}