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

asf-gitbox-commits pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 0ff22ae6a0392dfd2dbe73c96baa5a3fce897b15
Author: jsorel <[email protected]>
AuthorDate: Wed Sep 9 11:17:18 2026 +0200

    feat(SHP): support null geometries
---
 .../sis/storage/shapefile/shp/ShapeRecord.java     | 26 +++++++--
 .../sis/storage/shapefile/shp/ShapeWriter.java     |  5 ++
 .../sis/storage/shapefile/shp/ShapeIOTest.java     | 63 ++++++++++++++++++++++
 3 files changed, 91 insertions(+), 3 deletions(-)

diff --git 
a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeRecord.java
 
b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeRecord.java
index e31b3b9a34..335dc1d50b 100644
--- 
a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeRecord.java
+++ 
b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeRecord.java
@@ -67,14 +67,17 @@ public final class ShapeRecord {
     /**
      * Read this shape record.
      *
+     * A record declaring the {@link ShapeType#NULL} type has no geometry,
+     * such record may be found in a file of any other shape type.
+     *
      * @param channel input channel, not null
-     * @param io geometry decoder
+     * @param io geometry decoder, not null
      * @param filter optional filter envelope to stop geometry decoding as 
soon as possible
      * @return true if geometry pass the filter or if there is no filter
      * @throws IOException if an error occurred while reading.
      */
     public boolean read(final ChannelDataInput channel, ShapeGeometryEncoder 
io, Rectangle2D.Double filter) throws IOException {
-        if (io == null && filter != null) throw new 
IllegalArgumentException("filter must be null if encoder is null");
+        if (io == null) throw new IllegalArgumentException("encoder must not 
be null");
 
         channel.buffer.order(ByteOrder.BIG_ENDIAN);
         recordNumber = channel.readInt();
@@ -82,7 +85,15 @@ public final class ShapeRecord {
         final long position = channel.getStreamPosition();
         channel.buffer.order(ByteOrder.LITTLE_ENDIAN);
         final int shapeType = channel.readInt();
-        final boolean match = io.decode(channel,this, filter);
+        final boolean match;
+        if (shapeType == ShapeType.NULL.getCode()) {
+            //this record has no geometry, it can never match a filter area
+            geometry = null;
+            bbox = null;
+            match = filter == null;
+        } else {
+            match = io.decode(channel,this, filter);
+        }
         //always move to record end, size is sometime larger then the geometry 
bytes
         channel.seek(position + byteSize);
         return match;
@@ -90,6 +101,9 @@ public final class ShapeRecord {
 
     /**
      * Write this shape record.
+     *
+     * If the geometry is null the record is written as a {@link 
ShapeType#NULL} shape.
+     *
      * @param channel output channel to write into, not null
      * @param io geometry encoder
      * @throws IOException if an error occurred while writing.
@@ -97,6 +111,12 @@ public final class ShapeRecord {
     public void write(ChannelDataOutput channel, ShapeGeometryEncoder io) 
throws IOException {
         channel.buffer.order(ByteOrder.BIG_ENDIAN);
         channel.writeInt(recordNumber);
+        if (geometry == null) {
+            channel.writeInt(2); // the record contains only the 4 bytes of 
the shape type, size is in 16bit words
+            channel.buffer.order(ByteOrder.LITTLE_ENDIAN);
+            channel.writeInt(ShapeType.NULL.getCode());
+            return;
+        }
         channel.writeInt((io.getEncodedLength(geometry) + 4) / 2); // +4 for 
shape type and /2 because size is in 16bit words
         channel.buffer.order(ByteOrder.LITTLE_ENDIAN);
         channel.writeInt(io.getShapeType().getCode());
diff --git 
a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeWriter.java
 
b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeWriter.java
index eacf5ab287..89b87f2afa 100644
--- 
a/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeWriter.java
+++ 
b/incubator/src/org.apache.sis.storage.shapefile/main/org/apache/sis/storage/shapefile/shp/ShapeWriter.java
@@ -90,11 +90,16 @@ public final class ShapeWriter implements AutoCloseable{
     /**
      * Write a new record.
      *
+     * A record with a null geometry is written as a {@link ShapeType#NULL}.
+     *
      * @param record new record
      * @throws IOException If an I/O error occurs
      */
     public void writeRecord(ShapeRecord record) throws IOException {
         record.write(channel, io);
+        if (record.geometry == null) {
+            return;
+        }
         final GeneralEnvelope geomBox = io.getBoundingBox(record.geometry);
         if (bbox == null) {
             bbox = new GeneralEnvelope(geomBox.getDimension());
diff --git 
a/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/shp/ShapeIOTest.java
 
b/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/shp/ShapeIOTest.java
index f931979767..4e79becbcf 100644
--- 
a/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/shp/ShapeIOTest.java
+++ 
b/incubator/src/org.apache.sis.storage.shapefile/test/org/apache/sis/storage/shapefile/shp/ShapeIOTest.java
@@ -55,6 +55,13 @@ public class ShapeIOTest {
         return cdi;
     }
 
+    private ChannelDataInput openRead(Path path) throws DataStoreException {
+        final StorageConnector cnx = new StorageConnector(path);
+        final ChannelDataInput cdi = cnx.getStorageAs(ChannelDataInput.class);
+        cnx.closeAllExcept(cdi);
+        return cdi;
+    }
+
     private ChannelDataOutput openWrite(Path path) throws DataStoreException, 
IOException {
         final StorageConnector cnx = new StorageConnector(path);
         cnx.setOption(OptionKey.OPEN_OPTIONS, new 
OpenOption[]{StandardOpenOption.WRITE, StandardOpenOption.CREATE, 
StandardOpenOption.TRUNCATE_EXISTING});
@@ -348,4 +355,60 @@ public class ShapeIOTest {
 
         testReadAndWrite(path);
     }
+
+    /**
+     * Test writing and reading a null shape record.
+     */
+    @Test
+    public void testNullShape() throws Exception {
+        final String path = "/org/apache/sis/storage/shapefile/point.shp";
+        final ShapeHeader header;
+        final ShapeRecord record1;
+        final ShapeRecord record2;
+        try (ShapeReader reader = new ShapeReader(openRead(path), null)) {
+            header  = reader.getHeader();
+            record1 = reader.next();
+            record2 = reader.next();
+        }
+        record2.recordNumber = 3;
+
+        final Path tempFile = Files.createTempFile("tmp", ".shp");
+        try {
+            //insert a null shape between the two points
+            try (ShapeWriter writer = new ShapeWriter(openWrite(tempFile))) {
+                writer.writeHeader(header);
+                writer.writeRecord(record1);
+                writer.writeRecord(new ShapeRecord(2, null));
+                writer.writeRecord(record2);
+            }
+
+            try (ShapeReader reader = new ShapeReader(openRead(tempFile), 
null)) {
+                //a null shape does not contribute to the file bounding box
+                final ShapeHeader newHeader = reader.getHeader();
+                assertEquals(header.bbox.getMinimum(0), 
newHeader.bbox.getMinimum(0), 0.0001);
+                assertEquals(header.bbox.getMinimum(1), 
newHeader.bbox.getMinimum(1), 0.0001);
+                assertEquals(header.bbox.getMaximum(0), 
newHeader.bbox.getMaximum(0), 0.0001);
+                assertEquals(header.bbox.getMaximum(1), 
newHeader.bbox.getMaximum(1), 0.0001);
+
+                ShapeRecord record = reader.next();
+                assertEquals(1, record.recordNumber);
+                assertNotNull(record.geometry);
+
+                record = reader.next();
+                assertEquals(2, record.recordNumber);
+                assertNull(record.geometry);
+                assertNull(record.bbox);
+
+                //the record after the null shape must still be properly 
aligned
+                record = reader.next();
+                assertEquals(3, record.recordNumber);
+                assertEquals(((Point) record2.geometry).getX(), ((Point) 
record.geometry).getX(), 0.0001);
+
+                //no more records
+                assertNull(reader.next());
+            }
+        } finally {
+            Files.delete(tempFile);
+        }
+    }
 }

Reply via email to