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); + } + } }
