You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@baremaps.apache.org by bc...@apache.org on 2024/06/27 08:36:37 UTC

(incubator-baremaps) branch main updated: Add a flatgeobuf submodule (#881)

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

bchapuis pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-baremaps.git


The following commit(s) were added to refs/heads/main by this push:
     new 933ca83e Add a flatgeobuf submodule (#881)
933ca83e is described below

commit 933ca83ea371c8aae491e3cd10ceae88e5d03de8
Author: Bertil Chapuis <bc...@gmail.com>
AuthorDate: Thu Jun 27 10:36:34 2024 +0200

    Add a flatgeobuf submodule (#881)
    
    * Move support for flatgeobuf in a dedicated submodule
    
    * Add an API that supports reading and writing flatbuffers and domain objects corresponding to flatbuffers
    
    * Make picocli and jackson insensitive with enums
---
 baremaps-cli/src/license/override.properties       |   1 -
 .../java/org/apache/baremaps/cli/Baremaps.java     |   7 +-
 baremaps-core/pom.xml                              |   8 +-
 .../storage/flatgeobuf/FlatGeoBufDataTable.java    | 213 +++------
 .../flatgeobuf/FlatGeoBufTypeConversion.java       | 212 +++------
 .../apache/baremaps/utils/ObjectMapperUtils.java   |  10 +-
 .../flatgeobuf/FlatGeoBufDataTableTest.java        |   2 +-
 baremaps-flatgeobuf/pom.xml                        |  29 ++
 baremaps-flatgeobuf/src/main/fbs/feature.fbs       |  32 ++
 baremaps-flatgeobuf/src/main/fbs/header.fbs        |  94 ++++
 .../org/apache/baremaps/flatgeobuf/FlatGeoBuf.java | 143 ++++++
 .../baremaps/flatgeobuf/FlatGeoBufReader.java      | 403 ++++++++++++++++
 .../baremaps/flatgeobuf/FlatGeoBufWriter.java      | 292 ++++++++++++
 .../baremaps/flatgeobuf/GeometryConversions.java   | 454 ++++++++++++++++++
 .../apache/baremaps/flatgeobuf/PackedRTree.java    | 508 +++++++++++++++++++++
 .../baremaps/flatgeobuf/generated/Column.java      | 233 ++++++++++
 .../baremaps/flatgeobuf/generated/ColumnType.java  |  46 ++
 .../apache/baremaps/flatgeobuf/generated/Crs.java  | 185 ++++++++
 .../baremaps/flatgeobuf/generated/Feature.java     | 188 ++++++++
 .../baremaps/flatgeobuf/generated/Geometry.java    | 397 ++++++++++++++++
 .../flatgeobuf/generated/GeometryType.java         |  51 +++
 .../baremaps/flatgeobuf/generated/Header.java      | 340 ++++++++++++++
 .../apache/baremaps/flatgeobuf/FlatGeoBufTest.java | 207 +++++++++
 .../baremaps/geoparquet/GeoParquetReaderTest.java  |  25 +-
 baremaps-testing/pom.xml                           |   6 +
 pom.xml                                            |  20 +-
 scripts/generate-flatgeobuf.sh                     |   6 +
 27 files changed, 3788 insertions(+), 324 deletions(-)

diff --git a/baremaps-cli/src/license/override.properties b/baremaps-cli/src/license/override.properties
index 5fc3a8f9..50b03bba 100644
--- a/baremaps-cli/src/license/override.properties
+++ b/baremaps-cli/src/license/override.properties
@@ -142,7 +142,6 @@ org.postgresql--postgresql--42.7.2=BSD 2-Clause License
 org.reactivestreams--reactive-streams--1.0.4=MIT License
 org.roaringbitmap--RoaringBitmap--1.0.1=Apache License 2.0
 org.slf4j--slf4j-api--2.0.12=MIT License
-org.wololo--flatgeobuf--3.26.2=ISC License
 org.xerial--sqlite-jdbc--3.45.1.0=The Apache Software License, Version 2.0
 org.xerial.thirdparty--nestedvm--1.0=Apache License 2.0
 org.yaml--snakeyaml--2.2=Apache License 2.0
diff --git a/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java b/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java
index ca8ff7d6..3a0aee76 100644
--- a/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java
+++ b/baremaps-cli/src/main/java/org/apache/baremaps/cli/Baremaps.java
@@ -68,9 +68,12 @@ public class Baremaps implements Callable<Integer> {
     }
 
     // Execute the command
-    CommandLine cmd = new CommandLine(new Baremaps()).setUsageHelpLongOptionsMaxWidth(30)
+    CommandLine commandLine = new CommandLine(new Baremaps())
+        .setCaseInsensitiveEnumValuesAllowed(true)
+        .setUsageHelpLongOptionsMaxWidth(30)
         .addMixin("options", new Options());
-    cmd.execute(args);
+
+    commandLine.execute(args);
   }
 
   @Override
diff --git a/baremaps-core/pom.xml b/baremaps-core/pom.xml
index 28fc6af3..2684e792 100644
--- a/baremaps-core/pom.xml
+++ b/baremaps-core/pom.xml
@@ -82,6 +82,10 @@ limitations under the License.
       <groupId>org.apache.baremaps</groupId>
       <artifactId>baremaps-data</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.apache.baremaps</groupId>
+      <artifactId>baremaps-flatgeobuf</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.baremaps</groupId>
       <artifactId>baremaps-geoparquet</artifactId>
@@ -142,10 +146,6 @@ limitations under the License.
       <groupId>org.postgresql</groupId>
       <artifactId>postgresql</artifactId>
     </dependency>
-    <dependency>
-      <groupId>org.wololo</groupId>
-      <artifactId>flatgeobuf</artifactId>
-    </dependency>
     <dependency>
       <groupId>org.xerial</groupId>
       <artifactId>sqlite-jdbc</artifactId>
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTable.java b/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTable.java
index 1132f8b8..741c4e5a 100644
--- a/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTable.java
+++ b/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTable.java
@@ -17,7 +17,6 @@
 
 package org.apache.baremaps.storage.flatgeobuf;
 
-import com.google.flatbuffers.FlatBufferBuilder;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
@@ -26,18 +25,14 @@ import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.util.Iterator;
 import java.util.NoSuchElementException;
+import java.util.Objects;
 import org.apache.baremaps.data.collection.DataCollection;
-import org.apache.baremaps.data.storage.DataRow;
-import org.apache.baremaps.data.storage.DataSchema;
-import org.apache.baremaps.data.storage.DataStoreException;
-import org.apache.baremaps.data.storage.DataTable;
-import org.locationtech.jts.geom.*;
-import org.wololo.flatgeobuf.Constants;
-import org.wololo.flatgeobuf.GeometryConversions;
-import org.wololo.flatgeobuf.HeaderMeta;
-import org.wololo.flatgeobuf.PackedRTree;
-import org.wololo.flatgeobuf.generated.Feature;
-import org.wololo.flatgeobuf.generated.GeometryType;
+import org.apache.baremaps.data.storage.*;
+import org.apache.baremaps.data.storage.DataColumn.Type;
+import org.apache.baremaps.flatgeobuf.FlatGeoBuf;
+import org.apache.baremaps.flatgeobuf.FlatGeoBufReader;
+import org.apache.baremaps.flatgeobuf.FlatGeoBufWriter;
+import org.apache.baremaps.flatgeobuf.PackedRTree;
 
 /**
  * A {@link DataTable} that stores rows in a flatgeobuf file.
@@ -54,8 +49,18 @@ public class FlatGeoBufDataTable implements DataTable {
    * @param file the path to the flatgeobuf file
    */
   public FlatGeoBufDataTable(Path file) {
+    this(file, readSchema(file));
+  }
+
+  /**
+   * Constructs a table from a flatgeobuf file and a schema (used for writing).
+   *
+   * @param file the path to the flatgeobuf file
+   * @param schema the schema of the table
+   */
+  public FlatGeoBufDataTable(Path file, DataSchema schema) {
     this.file = file;
-    this.schema = readSchema(file);
+    this.schema = schema;
   }
 
   /**
@@ -65,27 +70,15 @@ public class FlatGeoBufDataTable implements DataTable {
    * @return the schema of the table
    */
   private static DataSchema readSchema(Path file) {
-    try (var channel = FileChannel.open(file, StandardOpenOption.READ)) {
+    try (var reader = new FlatGeoBufReader(FileChannel.open(file, StandardOpenOption.READ))) {
       // try to read the schema from the file
-      var buffer = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
-      HeaderMeta headerMeta = readHeaderMeta(channel, buffer);
-      return FlatGeoBufTypeConversion.asSchema(headerMeta);
+      var header = reader.readHeader();
+      return FlatGeoBufTypeConversion.asSchema(header);
     } catch (IOException e) {
       return null;
     }
   }
 
-  /**
-   * Constructs a table from a flatgeobuf file and a schema (used for writing).
-   *
-   * @param file the path to the flatgeobuf file
-   * @param schema the schema of the table
-   */
-  public FlatGeoBufDataTable(Path file, DataSchema schema) {
-    this.file = file;
-    this.schema = schema;
-  }
-
   /**
    * {@inheritDoc}
    */
@@ -100,22 +93,13 @@ public class FlatGeoBufDataTable implements DataTable {
   @Override
   public Iterator<DataRow> iterator() {
     try {
-      var channel = FileChannel.open(file, StandardOpenOption.READ);
+      var reader = new FlatGeoBufReader(FileChannel.open(file, StandardOpenOption.READ));
 
-      var buffer = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
-      HeaderMeta headerMeta = readHeaderMeta(channel, buffer);
-      channel.position(headerMeta.offset);
-
-      // skip the index
-      long indexOffset = headerMeta.offset;
-      long indexSize =
-          PackedRTree.calcSize((int) headerMeta.featuresCount, headerMeta.indexNodeSize);
-      channel.position(indexOffset + indexSize);
-
-      buffer.clear();
+      var header = reader.readHeader();
+      reader.skipIndex();
 
       // create the feature stream
-      return new RowIterator(channel, headerMeta, schema, buffer);
+      return new RowIterator(reader, header, schema);
     } catch (IOException e) {
       throw new DataStoreException(e);
     }
@@ -134,30 +118,14 @@ public class FlatGeoBufDataTable implements DataTable {
    */
   @Override
   public long size() {
-    try (var channel = FileChannel.open(file, StandardOpenOption.READ)) {
-      var buffer = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
-      HeaderMeta headerMeta = readHeaderMeta(channel, buffer);
-      return headerMeta.featuresCount;
+    try (var reader = new FlatGeoBufReader(FileChannel.open(file, StandardOpenOption.READ))) {
+      FlatGeoBuf.Header header = reader.readHeader();
+      return header.featuresCount();
     } catch (IOException e) {
       throw new DataStoreException(e);
     }
   }
 
-  /**
-   * Reads the header meta from a channel.
-   *
-   * @param channel the channel to read from
-   * @param buffer the buffer to use
-   * @return the header meta
-   * @throws IOException if an error occurs while reading the header meta
-   */
-  private static HeaderMeta readHeaderMeta(SeekableByteChannel channel, ByteBuffer buffer)
-      throws IOException {
-    channel.read(buffer);
-    buffer.flip();
-    return HeaderMeta.read(buffer);
-  }
-
   /**
    * Writes a collection of rows to a flatgeobuf file.
    *
@@ -166,68 +134,45 @@ public class FlatGeoBufDataTable implements DataTable {
    */
   public void write(DataCollection<DataRow> features) throws IOException {
     try (
-        var channel = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
-        var outputStream = Channels.newOutputStream(channel)) {
-      outputStream.write(Constants.MAGIC_BYTES);
-
-      var bufferBuilder = new FlatBufferBuilder();
-
-      var headerMeta = new HeaderMeta();
-      headerMeta.geometryType = GeometryType.Unknown;
-      headerMeta.indexNodeSize = 16;
-      headerMeta.srid = 3857;
-      headerMeta.featuresCount = features.size();
-      headerMeta.name = schema.name();
-      headerMeta.columns = FlatGeoBufTypeConversion.asColumns(schema.columns());
-      HeaderMeta.write(headerMeta, outputStream, bufferBuilder);
+        var writer = new FlatGeoBufWriter(
+            FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE))) {
+
+      schema.columns().stream()
+          .filter(c -> c.cardinality() == DataColumn.Cardinality.REQUIRED)
+          .forEach(c -> {
+            if (Objects.requireNonNull(c.type()) == Type.BINARY) {
+              throw new UnsupportedOperationException();
+            }
+          });
+
+      var header = new FlatGeoBuf.Header(
+          schema.name(),
+          null,
+          FlatGeoBuf.GeometryType.UNKNOWN,
+          false,
+          false,
+          false,
+          false,
+          FlatGeoBufTypeConversion.asColumns(schema.columns()),
+          features.size(),
+          2,
+          null,
+          null,
+          null,
+          null);
+
+      writer.writeHeader(header);
 
       var indexSize =
-          (int) PackedRTree.calcSize((int) headerMeta.featuresCount, headerMeta.indexNodeSize);
+          (int) PackedRTree.calcSize((int) header.featuresCount(), header.indexNodeSize());
 
-      for (int i = 0; i < indexSize; i++) {
-        outputStream.write(0);
-      }
+      writer.writeIndexBuffer(ByteBuffer.allocate(indexSize).order(ByteOrder.LITTLE_ENDIAN));
 
       var iterator = features.iterator();
       while (iterator.hasNext()) {
-        var featureBuilder = new FlatBufferBuilder(4096);
-
         var row = iterator.next();
-
-        var propertiesBuffer = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
-        var properties = row.values().stream()
-            .filter(v -> !(v instanceof Geometry))
-            .toList();
-        for (int i = 0; i < properties.size(); i++) {
-          var column = headerMeta.columns.get(i);
-          var value = properties.get(i);
-          propertiesBuffer.putShort((short) i);
-          FlatGeoBufTypeConversion.writeValue(propertiesBuffer, column, value);
-        }
-        if (propertiesBuffer.position() > 0) {
-          propertiesBuffer.flip();
-        }
-        var propertiesOffset = org.wololo.flatgeobuf.generated.Feature
-            .createPropertiesVector(featureBuilder, propertiesBuffer);
-
-        var geometry = row.values().stream()
-            .filter(v -> v instanceof Geometry)
-            .map(Geometry.class::cast)
-            .findFirst();
-
-        var geometryOffset = geometry.isPresent()
-            ? GeometryConversions.serialize(featureBuilder, geometry.get(), headerMeta.geometryType)
-            : 0;
-
-        var featureOffset =
-            org.wololo.flatgeobuf.generated.Feature.createFeature(featureBuilder, geometryOffset,
-                propertiesOffset, 0);
-        featureBuilder.finishSizePrefixed(featureOffset);
-
-        ByteBuffer data = featureBuilder.dataBuffer();
-        while (data.hasRemaining()) {
-          channel.write(data);
-        }
+        var feature = FlatGeoBufTypeConversion.asFeature(row);
+        writer.writeFeature(feature);
       }
     }
   }
@@ -237,30 +182,28 @@ public class FlatGeoBufDataTable implements DataTable {
    */
   public static class RowIterator implements Iterator<DataRow> {
 
-    private final HeaderMeta headerMeta;
+    private final FlatGeoBuf.Header header;
 
     private final DataSchema schema;
 
-    private final SeekableByteChannel channel;
-
-    private final ByteBuffer buffer;
+    private final FlatGeoBufReader reader;
 
     private long cursor = 0;
 
     /**
      * Constructs a row iterator.
      *
-     * @param channel the channel to read from
-     * @param headerMeta the header meta
+     * @param reader the channel to read from
+     * @param header the header of the file
      * @param schema the schema of the table
-     * @param buffer the buffer to use
      */
-    public RowIterator(SeekableByteChannel channel, HeaderMeta headerMeta,
-        DataSchema schema, ByteBuffer buffer) {
-      this.channel = channel;
-      this.headerMeta = headerMeta;
+    public RowIterator(
+        FlatGeoBufReader reader,
+        FlatGeoBuf.Header header,
+        DataSchema schema) {
+      this.reader = reader;
+      this.header = header;
       this.schema = schema;
-      this.buffer = buffer;
     }
 
     /**
@@ -268,7 +211,7 @@ public class FlatGeoBufDataTable implements DataTable {
      */
     @Override
     public boolean hasNext() {
-      return cursor < headerMeta.featuresCount;
+      return cursor < header.featuresCount();
     }
 
     /**
@@ -277,19 +220,9 @@ public class FlatGeoBufDataTable implements DataTable {
     @Override
     public DataRow next() {
       try {
-        channel.read(buffer);
-        buffer.flip();
-
-        var featureSize = buffer.getInt();
-        var row =
-            FlatGeoBufTypeConversion.asRow(headerMeta, schema, Feature.getRootAsFeature(buffer));
-
-        buffer.position(Integer.BYTES + featureSize);
-        buffer.compact();
-
+        var feature = reader.readFeature();
         cursor++;
-
-        return row;
+        return FlatGeoBufTypeConversion.asRow(schema, feature);
       } catch (IOException e) {
         throw new NoSuchElementException(e);
       }
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufTypeConversion.java b/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufTypeConversion.java
index c65e3109..b0339834 100644
--- a/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufTypeConversion.java
+++ b/baremaps-core/src/main/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufTypeConversion.java
@@ -18,22 +18,14 @@
 package org.apache.baremaps.storage.flatgeobuf;
 
 
-import com.google.flatbuffers.FlatBufferBuilder;
-import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.nio.channels.WritableByteChannel;
-import java.nio.charset.StandardCharsets;
 import java.util.*;
 import org.apache.baremaps.data.storage.*;
 import org.apache.baremaps.data.storage.DataColumn.Cardinality;
 import org.apache.baremaps.data.storage.DataColumn.Type;
-import org.wololo.flatgeobuf.ColumnMeta;
-import org.wololo.flatgeobuf.GeometryConversions;
-import org.wololo.flatgeobuf.HeaderMeta;
-import org.wololo.flatgeobuf.generated.ColumnType;
-import org.wololo.flatgeobuf.generated.Crs;
-import org.wololo.flatgeobuf.generated.Feature;
-import org.wololo.flatgeobuf.generated.Header;
+import org.apache.baremaps.flatgeobuf.FlatGeoBuf;
+import org.apache.baremaps.flatgeobuf.FlatGeoBuf.Feature;
+import org.apache.baremaps.flatgeobuf.generated.ColumnType;
+import org.locationtech.jts.geom.Geometry;
 
 public class FlatGeoBufTypeConversion {
 
@@ -54,170 +46,86 @@ public class FlatGeoBufTypeConversion {
     // Prevent instantiation
   }
 
-  public static DataSchema asSchema(HeaderMeta headerMeta) {
-    var name = headerMeta.name;
-    var columns = headerMeta.columns.stream()
+  public static DataSchema asSchema(FlatGeoBuf.Header header) {
+    var name = header.name();
+    var columns = header.columns().stream()
         .map(column -> new DataColumnFixed(
-            column.name,
-            column.nullable ? Cardinality.OPTIONAL : Cardinality.REQUIRED,
-            Type.fromBinding(column.getBinding())))
+            column.name(),
+            column.nullable() ? Cardinality.OPTIONAL : Cardinality.REQUIRED,
+            Type.fromBinding(fromColumnType(column.type()))))
         .map(DataColumn.class::cast)
         .toList();
     return new DataSchemaImpl(name, columns);
   }
 
-  public static DataRow asRow(HeaderMeta headerMeta, DataSchema dataType, Feature feature) {
+  private static Class<?> fromColumnType(FlatGeoBuf.ColumnType columnType) {
+    return switch (columnType) {
+      case BYTE -> Byte.class;
+      case UBYTE -> Byte.class;
+      case BOOL -> Boolean.class;
+      case SHORT -> Short.class;
+      case USHORT -> Short.class;
+      case INT -> Integer.class;
+      case UINT -> Integer.class;
+      case LONG -> Long.class;
+      case ULONG -> Long.class;
+      case FLOAT -> Float.class;
+      case DOUBLE -> Double.class;
+      case STRING -> String.class;
+      case JSON -> throw new UnsupportedOperationException();
+      case DATETIME -> throw new UnsupportedOperationException();
+      case BINARY -> throw new UnsupportedOperationException();
+    };
+  }
+
+  public static DataRow asRow(DataSchema dataType, Feature feature) {
     var values = new ArrayList<>();
 
-    var geometryBuffer = feature.geometry();
-    var geometry = GeometryConversions.deserialize(geometryBuffer, geometryBuffer.type());
+    var geometry = feature.geometry();
     values.add(geometry);
 
-    if (feature.propertiesLength() > 0) {
-      var propertiesBuffer = feature.propertiesAsByteBuffer();
-      while (propertiesBuffer.hasRemaining()) {
-        var type = propertiesBuffer.getShort();
-        var column = headerMeta.columns.get(type);
-        var value = readValue(propertiesBuffer, column);
-        values.add(value);
-      }
+    if (!feature.properties().isEmpty()) {
+      values.addAll(feature.properties());
     }
 
     return new DataRowImpl(dataType, values);
   }
 
-  public static void writeHeaderMeta(HeaderMeta headerMeta, WritableByteChannel channel,
-      FlatBufferBuilder builder) throws IOException {
-    int[] columnsArray = headerMeta.columns.stream().mapToInt(c -> {
-      int nameOffset = builder.createString(c.name);
-      int type = c.type;
-      return org.wololo.flatgeobuf.generated.Column.createColumn(builder, nameOffset, type, 0, 0,
-          c.width, c.precision, c.scale, c.nullable, c.unique,
-          c.primary_key, 0);
-    }).toArray();
-    int columnsOffset = Header.createColumnsVector(builder, columnsArray);
-
-    int nameOffset = 0;
-    if (headerMeta.name != null) {
-      nameOffset = builder.createString(headerMeta.name);
-    }
-    int crsOffset = 0;
-    if (headerMeta.srid != 0) {
-      Crs.startCrs(builder);
-      Crs.addCode(builder, headerMeta.srid);
-      crsOffset = Crs.endCrs(builder);
-    }
-    int envelopeOffset = 0;
-    if (headerMeta.envelope != null) {
-      envelopeOffset = Header.createEnvelopeVector(builder,
-          new double[] {headerMeta.envelope.getMinX(), headerMeta.envelope.getMinY(),
-              headerMeta.envelope.getMaxX(), headerMeta.envelope.getMaxY()});
-    }
-    Header.startHeader(builder);
-    Header.addGeometryType(builder, headerMeta.geometryType);
-    Header.addIndexNodeSize(builder, headerMeta.indexNodeSize);
-    Header.addColumns(builder, columnsOffset);
-    Header.addEnvelope(builder, envelopeOffset);
-    Header.addName(builder, nameOffset);
-    Header.addCrs(builder, crsOffset);
-    Header.addFeaturesCount(builder, headerMeta.featuresCount);
-    int offset = Header.endHeader(builder);
-
-    builder.finishSizePrefixed(offset);
-
-    ByteBuffer dataBuffer = builder.dataBuffer();
-    while (dataBuffer.hasRemaining())
-      channel.write(dataBuffer);
-  }
-
-  public static Object readValue(ByteBuffer propertiesBuffer, ColumnMeta column) {
-    return switch (column.type) {
-      case ColumnType.Byte -> propertiesBuffer.get();
-      case ColumnType.Bool -> propertiesBuffer.get() == 1;
-      case ColumnType.Short -> propertiesBuffer.getShort();
-      case ColumnType.Int -> propertiesBuffer.getInt();
-      case ColumnType.Long -> propertiesBuffer.getLong();
-      case ColumnType.Float -> propertiesBuffer.getFloat();
-      case ColumnType.Double -> propertiesBuffer.getDouble();
-      case ColumnType.String -> readString(propertiesBuffer);
-      case ColumnType.Json -> readJson(propertiesBuffer);
-      case ColumnType.DateTime -> readDateTime(propertiesBuffer);
-      case ColumnType.Binary -> readBinary(propertiesBuffer);
-      default -> null;
-    };
-  }
-
-  public static void writeValue(ByteBuffer propertiesBuffer, ColumnMeta column, Object value) {
-    switch (column.type) {
-      case ColumnType.Byte -> propertiesBuffer.put((byte) value);
-      case ColumnType.Bool -> propertiesBuffer.put((byte) ((boolean) value ? 1 : 0));
-      case ColumnType.Short -> propertiesBuffer.putShort((short) value);
-      case ColumnType.Int -> propertiesBuffer.putInt((int) value);
-      case ColumnType.Long -> propertiesBuffer.putLong((long) value);
-      case ColumnType.Float -> propertiesBuffer.putFloat((float) value);
-      case ColumnType.Double -> propertiesBuffer.putDouble((double) value);
-      case ColumnType.String -> writeString(propertiesBuffer, value);
-      case ColumnType.Json -> writeJson(propertiesBuffer, value);
-      case ColumnType.DateTime -> writeDateTime(propertiesBuffer, value);
-      case ColumnType.Binary -> writeBinary(propertiesBuffer, value);
-      default -> {
-        // Do nothing
-      }
-    };
-  }
-
-  public static void writeString(ByteBuffer propertiesBuffer, Object value) {
-    var bytes = ((String) value).getBytes(StandardCharsets.UTF_8);
-    propertiesBuffer.putInt(bytes.length);
-    propertiesBuffer.put(bytes);
-  }
-
-  public static void writeJson(ByteBuffer propertiesBuffer, Object value) {
-    throw new UnsupportedOperationException();
-  }
-
-  public static void writeDateTime(ByteBuffer propertiesBuffer, Object value) {
-    throw new UnsupportedOperationException();
-  }
-
-  public static void writeBinary(ByteBuffer propertiesBuffer, Object value) {
-    throw new UnsupportedOperationException();
-  }
-
-  public static Object readString(ByteBuffer buffer) {
-    var length = buffer.getInt();
-    var bytes = new byte[length];
-    buffer.get(bytes);
-    return new String(bytes, StandardCharsets.UTF_8);
-  }
-
-  public static Object readJson(ByteBuffer buffer) {
-    throw new UnsupportedOperationException();
-  }
-
-  public static Object readDateTime(ByteBuffer buffer) {
-    throw new UnsupportedOperationException();
-  }
-
-  public static Object readBinary(ByteBuffer buffer) {
-    throw new UnsupportedOperationException();
-  }
-
-  public static List<ColumnMeta> asColumns(List<DataColumn> columns) {
+  public static List<FlatGeoBuf.Column> asColumns(List<DataColumn> columns) {
     return columns.stream()
         .map(FlatGeoBufTypeConversion::asColumn)
         .filter(Objects::nonNull)
         .toList();
   }
 
-  public static ColumnMeta asColumn(DataColumn column) {
+  public static FlatGeoBuf.Column asColumn(DataColumn column) {
     var type = types.get(column.type());
     if (type == null) {
       return null;
     }
-    var columnMeta = new ColumnMeta();
-    columnMeta.name = column.name();
-    columnMeta.type = type.byteValue();
-    return columnMeta;
+    return new FlatGeoBuf.Column(
+        column.name(),
+        FlatGeoBuf.ColumnType.values()[type],
+        null,
+        null,
+        0,
+        0,
+        0,
+        column.cardinality() == Cardinality.OPTIONAL,
+        false,
+        false,
+        null);
+  }
+
+  public static FlatGeoBuf.Feature asFeature(DataRow row) {
+    var geometry = row.values().stream()
+        .filter(v -> v instanceof Geometry)
+        .map(Geometry.class::cast)
+        .findFirst()
+        .orElse(null);
+    var properties = row.values().stream()
+        .filter(v -> !(v instanceof Geometry))
+        .toList();
+    return new FlatGeoBuf.Feature(properties, geometry);
   }
 }
diff --git a/baremaps-core/src/main/java/org/apache/baremaps/utils/ObjectMapperUtils.java b/baremaps-core/src/main/java/org/apache/baremaps/utils/ObjectMapperUtils.java
index 50a4af26..9b2cc9cb 100644
--- a/baremaps-core/src/main/java/org/apache/baremaps/utils/ObjectMapperUtils.java
+++ b/baremaps-core/src/main/java/org/apache/baremaps/utils/ObjectMapperUtils.java
@@ -21,7 +21,9 @@ package org.apache.baremaps.utils;
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
 import com.fasterxml.jackson.core.JsonGenerator.Feature;
 import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.MapperFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.json.JsonMapper;
 import org.apache.baremaps.maplibre.expression.Expressions;
 
 public class ObjectMapperUtils {
@@ -31,11 +33,13 @@ public class ObjectMapperUtils {
   }
 
   public static ObjectMapper objectMapper() {
-    return new ObjectMapper()
-        .registerModule(Expressions.createModule())
+    return JsonMapper.builder()
+        .addModule(Expressions.createModule())
         .configure(Feature.IGNORE_UNKNOWN, true)
         .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
         .configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true)
-        .setSerializationInclusion(Include.NON_NULL).setSerializationInclusion(Include.NON_EMPTY);
+        .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true)
+        .serializationInclusion(Include.NON_NULL).serializationInclusion(Include.NON_EMPTY)
+        .build();
   }
 }
diff --git a/baremaps-core/src/test/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTableTest.java b/baremaps-core/src/test/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTableTest.java
index d277e10f..cfc88ee9 100644
--- a/baremaps-core/src/test/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTableTest.java
+++ b/baremaps-core/src/test/java/org/apache/baremaps/storage/flatgeobuf/FlatGeoBufDataTableTest.java
@@ -31,7 +31,7 @@ class FlatGeoBufDataTableTest {
     var file = TestFiles.resolve("baremaps-testing/data/samples/countries.fgb");
     var table = new FlatGeoBufDataTable(file);
     var rowType = table.schema();
-    assertEquals(null, rowType.name());
+    assertEquals("countries", rowType.name());
     assertEquals(2, rowType.columns().size());
   }
 
diff --git a/baremaps-flatgeobuf/pom.xml b/baremaps-flatgeobuf/pom.xml
new file mode 100644
index 00000000..e1955007
--- /dev/null
+++ b/baremaps-flatgeobuf/pom.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+  <parent>
+    <groupId>org.apache.baremaps</groupId>
+    <artifactId>baremaps</artifactId>
+    <version>0.7.4-SNAPSHOT</version>
+  </parent>
+  <artifactId>baremaps-flatgeobuf</artifactId>
+  <dependencies>
+    <dependency>
+      <groupId>com.google.flatbuffers</groupId>
+      <artifactId>flatbuffers-java</artifactId>
+      <version>24.3.25</version>
+    </dependency>
+    <dependency>
+      <groupId>com.google.guava</groupId>
+      <artifactId>guava</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.baremaps</groupId>
+      <artifactId>baremaps-testing</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>org.locationtech.jts</groupId>
+      <artifactId>jts-core</artifactId>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/baremaps-flatgeobuf/src/main/fbs/feature.fbs b/baremaps-flatgeobuf/src/main/fbs/feature.fbs
new file mode 100644
index 00000000..ddcb5ee0
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/fbs/feature.fbs
@@ -0,0 +1,32 @@
+// Copyright (c) 2018, Björn Harrtell
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PR [...]
+
+include "header.fbs";
+
+namespace FlatGeobuf;
+
+table Geometry {
+  ends: [uint];          // Array of end index in flat coordinates per geometry part with exterior ring first (NOTE: allowed and recommended to be null if 1 part)
+  xy: [double];          // Flat x and y coordinate array (flat pairs) (should not be null or empty)
+  z: [double];           // Flat z height array (should not be null or empty if header hasZ)
+  m: [double];           // Flat m measurement array (should not be null or empty if header hasM)
+  t: [double];           // Flat t geodetic decimal year time array (should not be null or empty if header hasT)
+  tm: [ulong];           // Flat tm time nanosecond measurement array (should not be null or empty if header hasTM)
+  type: GeometryType;    // Type of geometry (only for elements in heterogeneous collection types or if unknown in header)
+  parts: [Geometry];     // Array of parts (for heterogeneous collection types)
+}
+
+table Feature {
+  geometry: Geometry;  // Geometry
+  properties: [ubyte]; // Custom buffer, variable length collection of key/value pairs (key=ushort)
+  columns: [Column];   // Attribute columns schema (optional)
+}
+
+root_type Feature;
\ No newline at end of file
diff --git a/baremaps-flatgeobuf/src/main/fbs/header.fbs b/baremaps-flatgeobuf/src/main/fbs/header.fbs
new file mode 100644
index 00000000..48500853
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/fbs/header.fbs
@@ -0,0 +1,94 @@
+// Copyright (c) 2018, Björn Harrtell
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+// 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PR [...]
+
+namespace FlatGeobuf;
+
+// Geometry type enumeration
+// NOTE: Same as WKB 2D geometry type enumeration
+enum GeometryType: ubyte {
+  Unknown = 0,
+  Point = 1,
+  LineString = 2,
+  Polygon = 3,
+  MultiPoint = 4,
+  MultiLineString = 5,
+  MultiPolygon = 6,
+  GeometryCollection = 7,
+  CircularString = 8,
+  CompoundCurve = 9,
+  CurvePolygon = 10,
+  MultiCurve = 11,
+  MultiSurface = 12,
+  Curve = 13,
+  Surface = 14,
+  PolyhedralSurface = 15,
+  TIN = 16,
+  Triangle = 17
+}
+
+enum ColumnType: ubyte {
+  Byte,                         // Signed 8-bit integer
+  UByte,                        // Unsigned 8-bit integer
+  Bool,                         // Boolean
+  Short,                        // Signed 16-bit integer
+  UShort,                       // Unsigned 16-bit integer
+  Int,                          // Signed 32-bit integer
+  UInt,                         // Unsigned 32-bit integer
+  Long,                         // Signed 64-bit integer
+  ULong,                        // Unsigned 64-bit integer
+  Float,                        // Single precision floating point number
+  Double,                       // Double precision floating point number
+  String,                       // UTF8 string
+  Json,                         // General JSON type intended to be application specific
+  DateTime,                     // ISO 8601 date time
+  Binary                        // General binary type intended to be application specific
+}
+
+table Column {
+  name: string (required);      // Column name
+  type: ColumnType;             // Column type
+  title: string;                // Column title
+  description: string;          // Column description (intended for free form long text)
+  width: int = -1;              // Column values expected width (-1 = unknown) (currently only used to indicate the number of characters in strings)
+  precision: int = -1;          // Column values expected precision (-1 = unknown) as defined by SQL
+  scale: int = -1;              // Column values expected scale (-1 = unknown) as defined by SQL
+  nullable: bool = true;        // Column values expected nullability
+  unique: bool = false;         // Column values expected uniqueness
+  primary_key: bool = false;    // Indicates this column has been (part of) a primary key
+  metadata: string;             // Column metadata (intended to be application specific and suggested to be structured fx. JSON)
+}
+
+table Crs {
+  org: string;                  // Case-insensitive name of the defining organization e.g. EPSG or epsg (NULL = EPSG)
+  code: int;                    // Numeric ID of the Spatial Reference System assigned by the organization (0 = unknown)
+  name: string;                 // Human readable name of this SRS
+  description: string;          // Human readable description of this SRS
+  wkt: string;                  // Well-known Text Representation of the Spatial Reference System
+  code_string: string;          // Text ID of the Spatial Reference System assigned by the organization in the (rare) case when it is not an integer and thus cannot be set into code
+}
+
+table Header {
+  name: string;                 // Dataset name
+  envelope: [double];           // Bounds
+  geometry_type: GeometryType;  // Geometry type (should be set to Unknown if per feature geometry type)
+  has_z: bool = false;           // Does geometry have Z dimension?
+  has_m: bool = false;           // Does geometry have M dimension?
+  has_t: bool = false;           // Does geometry have T dimension?
+  has_tm: bool = false;          // Does geometry have TM dimension?
+  columns: [Column];            // Attribute columns schema (can be omitted if per feature schema)
+  features_count: ulong;        // Number of features in the dataset (0 = unknown)
+  index_node_size: ushort = 16; // Index node size (0 = no index)
+  crs: Crs;                     // Spatial Reference System
+  title: string;                // Dataset title
+  description: string;          // Dataset description (intended for free form long text)
+  metadata: string;             // Dataset metadata (intended to be application specific and suggested to be structured fx. JSON)
+}
+
+root_type Header;
\ No newline at end of file
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBuf.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBuf.java
new file mode 100644
index 00000000..e0ccce8a
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBuf.java
@@ -0,0 +1,143 @@
+/*
+ * 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.baremaps.flatgeobuf;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import org.locationtech.jts.geom.Geometry;
+
+/**
+ * This class describes the domain model for FlatGeoBuf.
+ *
+ * This code has been adapted from FlatGeoBuf (BSD 2-Clause "Simplified" License).
+ * <p>
+ * Copyright (c) 2018, Björn Harrtell
+ */
+public class FlatGeoBuf {
+
+  protected static final byte[] MAGIC_BYTES =
+      new byte[] {0x66, 0x67, 0x62, 0x03, 0x66, 0x67, 0x62, 0x00};
+
+  private FlatGeoBuf() {
+    // Prevent instantiation
+  }
+
+  public static boolean isFlatGeoBuf(ByteBuffer bb) {
+    return bb.get() == MAGIC_BYTES[0] &&
+        bb.get() == MAGIC_BYTES[1] &&
+        bb.get() == MAGIC_BYTES[2] &&
+        bb.get() == MAGIC_BYTES[3] &&
+        bb.get() == MAGIC_BYTES[4] &&
+        bb.get() == MAGIC_BYTES[5] &&
+        bb.get() == MAGIC_BYTES[6] &&
+        bb.get() == MAGIC_BYTES[7];
+  }
+
+  // Geometry type enumeration
+  public enum GeometryType {
+    UNKNOWN(0),
+    POINT(1),
+    LINESTRING(2),
+    POLYGON(3),
+    MULTIPOINT(4),
+    MULTILINESTRING(5),
+    MULTIPOLYGON(6),
+    GEOMETRYCOLLECTION(7),
+    CIRCULARSTRING(8),
+    COMPOUNDCURVE(9),
+    CURVEPOLYGON(10),
+    MULTICURVE(11),
+    MULTISURFACE(12),
+    CURVE(13),
+    SURFACE(14),
+    POLYHEDRALSURFACE(15),
+    TIN(16),
+    TRIANGLE(17);
+
+    private final int value;
+
+    GeometryType(int value) {
+      this.value = value;
+    }
+
+    public int getValue() {
+      return value;
+    }
+  }
+
+  public enum ColumnType {
+    BYTE,
+    UBYTE,
+    BOOL,
+    SHORT,
+    USHORT,
+    INT,
+    UINT,
+    LONG,
+    ULONG,
+    FLOAT,
+    DOUBLE,
+    STRING,
+    JSON,
+    DATETIME,
+    BINARY
+  }
+
+  public record Column(
+      String name,
+      ColumnType type,
+      String title,
+      String description,
+      int width,
+      int precision,
+      int scale,
+      boolean nullable,
+      boolean unique,
+      boolean primaryKey,
+      String metadata) {
+  }
+
+  public record Crs(
+      String org,
+      int code,
+      String name,
+      String description,
+      String wkt,
+      String codeString) {
+  }
+
+  public record Header(
+      String name,
+      List<Double> envelope,
+      GeometryType geometryType,
+      boolean hasZ,
+      boolean hasM,
+      boolean hasT,
+      boolean hasTM,
+      List<Column> columns,
+      long featuresCount,
+      int indexNodeSize,
+      Crs crs,
+      String title,
+      String description,
+      String metadata) {
+  }
+
+  public record Feature(List<Object> properties, Geometry geometry) {
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBufReader.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBufReader.java
new file mode 100644
index 00000000..d453b31b
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBufReader.java
@@ -0,0 +1,403 @@
+/*
+ * 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.baremaps.flatgeobuf;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.IntStream;
+import org.apache.baremaps.flatgeobuf.generated.Column;
+import org.apache.baremaps.flatgeobuf.generated.Feature;
+import org.apache.baremaps.flatgeobuf.generated.Header;
+import org.locationtech.jts.geom.Geometry;
+
+/**
+ * This class contains the logic for reading FlatGeoBuf files. It can either read FlatBuffers
+ * directly or convert them to a domain model.
+ * <p>
+ * This code has been adapted from FlatGeoBuf (BSD 2-Clause "Simplified" License).
+ * <p>
+ * Copyright (c) 2018, Bj&ouml;rn Harrtell
+ */
+public class FlatGeoBufReader implements AutoCloseable {
+
+  private final ByteBuffer featureBuffer =
+      ByteBuffer.allocate(1 << 16).order(ByteOrder.LITTLE_ENDIAN);
+
+  private final ReadableByteChannel channel;
+
+  private Header header;
+
+  public FlatGeoBufReader(ReadableByteChannel channel) {
+    this.channel = channel;
+  }
+
+  public Header readHeaderBuffer() throws IOException {
+    header = readHeaderBuffer(channel);
+    return header;
+  }
+
+  public FlatGeoBuf.Header readHeader() throws IOException {
+    return asFlatGeoBuf(readHeaderBuffer());
+  }
+
+  public Feature readFeatureBuffer() throws IOException {
+    return readFeatureBuffer(channel, featureBuffer);
+  }
+
+  public FlatGeoBuf.Feature readFeature() throws IOException {
+    return readFeature(channel, header, featureBuffer);
+  }
+
+  public void skipIndex() throws IOException {
+    skipIndex(channel, header);
+  }
+
+  public ByteBuffer readIndexBuffer() throws IOException {
+    return readIndexBuffer(channel, header);
+  }
+
+  public InputStream readIndexStream() {
+    return readIndexStream(channel, header);
+  }
+
+  @Override
+  public void close() throws IOException {
+    channel.close();
+  }
+
+  public static Header readHeaderBuffer(ReadableByteChannel channel)
+      throws IOException {
+
+    // Check if the file is a flatgeobuf
+    ByteBuffer prefixBuffer = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
+    while (prefixBuffer.hasRemaining()) {
+      if (channel.read(prefixBuffer) == -1) {
+        break; // End of channel reached
+      }
+    }
+    prefixBuffer.flip();
+    if (!FlatGeoBuf.isFlatGeoBuf(prefixBuffer)) {
+      throw new IOException("This is not a flatgeobuf!");
+    }
+
+    // Read the header size
+    int headerSize = prefixBuffer.getInt();
+    ByteBuffer headerBuffer = ByteBuffer.allocate(headerSize).order(ByteOrder.LITTLE_ENDIAN);
+
+    // Read the header
+    while (headerBuffer.hasRemaining()) {
+      if (channel.read(headerBuffer) == -1) {
+        break; // End of channel reached
+      }
+    }
+
+    // Prepare the buffer for reading
+    headerBuffer.flip();
+
+    return Header.getRootAsHeader(headerBuffer);
+  }
+
+  public static FlatGeoBuf.Header readHeader(ReadableByteChannel channel)
+      throws IOException {
+    Header header = readHeaderBuffer(channel);
+    return asFlatGeoBuf(header);
+  }
+
+  public static FlatGeoBuf.Header asFlatGeoBuf(Header header) {
+    return new FlatGeoBuf.Header(
+        header.name(),
+        List.of(
+            header.envelope(0),
+            header.envelope(1),
+            header.envelope(2),
+            header.envelope(3)),
+        FlatGeoBuf.GeometryType.values()[header.geometryType()],
+        header.hasZ(),
+        header.hasM(),
+        header.hasT(),
+        header.hasTm(),
+        IntStream.range(0, header.columnsLength())
+            .mapToObj(header::columns)
+            .map(column -> new FlatGeoBuf.Column(
+                column.name(),
+                FlatGeoBuf.ColumnType.values()[column.type()],
+                column.title(),
+                column.description(),
+                column.width(),
+                column.precision(),
+                column.scale(),
+                column.nullable(),
+                column.unique(),
+                column.primaryKey(),
+                column.metadata()))
+            .toList(),
+        header.featuresCount(),
+        header.indexNodeSize(),
+        new FlatGeoBuf.Crs(
+            header.crs().org(),
+            header.crs().code(),
+            header.crs().name(),
+            header.crs().description(),
+            header.crs().wkt(),
+            header.crs().codeString()),
+        header.title(),
+        header.description(),
+        header.metadata());
+  }
+
+  public static Feature readFeatureBuffer(ReadableByteChannel channel, ByteBuffer buffer)
+      throws IOException {
+
+    try {
+      // Compact the buffer if it has been used before
+      if (buffer.position() > 0) {
+        buffer.compact();
+      }
+
+      // Fill the buffer
+      while (buffer.hasRemaining()) {
+        if (channel.read(buffer) == -1) {
+          break; // End of channel reached
+        }
+      }
+
+      // Read the feature size
+      buffer.flip();
+      int featureSize = buffer.getInt();
+
+      // Allocate a new buffer if the feature size is greater than the current buffer capacity
+      if (featureSize > buffer.remaining()) {
+        ByteBuffer newBuffer = ByteBuffer.allocate(featureSize).order(ByteOrder.LITTLE_ENDIAN);
+
+        // Copy the remaining bytes from the current buffer to the new buffer
+        newBuffer.put(buffer);
+
+        // Fill the new buffer with the remaining bytes
+        while (newBuffer.hasRemaining()) {
+          if (channel.read(newBuffer) == -1) {
+            break; // End of channel reached
+          }
+        }
+
+        // Prepare the new buffer for reading
+        newBuffer.flip();
+
+        // Read the feature from the new buffer
+        Feature feature = Feature.getRootAsFeature(newBuffer.duplicate());
+
+        // Clear the old buffer to prepare for the next read
+        buffer.clear();
+
+        return feature;
+
+      } else {
+        Feature feature = Feature.getRootAsFeature(buffer.slice(buffer.position(), featureSize));
+        buffer.position(buffer.position() + featureSize);
+
+        return feature;
+      }
+    } catch (BufferUnderflowException e) {
+      throw new IOException("Failed to read feature", e);
+    }
+  }
+
+  public static FlatGeoBuf.Feature readFeature(
+      ReadableByteChannel channel,
+      Header header, ByteBuffer buffer)
+      throws IOException {
+    Feature feature = readFeatureBuffer(channel, buffer);
+    return asFlatGeoBuf(header, feature);
+  }
+
+  public static FlatGeoBuf.Feature asFlatGeoBuf(Header header, Feature feature) {
+    var properties = new ArrayList<>();
+    if (feature.propertiesLength() > 0) {
+      var propertiesBuffer = feature.propertiesAsByteBuffer();
+      while (propertiesBuffer.hasRemaining()) {
+        var columnPosition = propertiesBuffer.getShort();
+        var columnType = header.columns(columnPosition);
+        var columnValue = readValue(propertiesBuffer, columnType);
+        properties.add(columnValue);
+      }
+    }
+    Geometry geometry =
+        GeometryConversions.readGeometry(feature.geometry(), header.geometryType());
+    return new FlatGeoBuf.Feature(properties, geometry);
+  }
+
+  private static Object readValue(ByteBuffer buffer, Column column) {
+    return switch (FlatGeoBuf.ColumnType.values()[column.type()]) {
+      case BYTE -> buffer.get();
+      case UBYTE -> buffer.get();
+      case BOOL -> buffer.get() == 1;
+      case SHORT -> buffer.getShort();
+      case USHORT -> buffer.getShort();
+      case INT -> buffer.getInt();
+      case UINT -> buffer.getInt();
+      case LONG -> buffer.getLong();
+      case ULONG -> buffer.getLong();
+      case FLOAT -> buffer.getFloat();
+      case DOUBLE -> buffer.getDouble();
+      case STRING -> readString(buffer);
+      case JSON -> readJson(buffer);
+      case DATETIME -> readDateTime(buffer);
+      case BINARY -> readBinary(buffer);
+    };
+  }
+
+  private static Object readString(ByteBuffer buffer) {
+    var length = buffer.getInt();
+    var bytes = new byte[length];
+    buffer.get(bytes);
+    return new String(bytes, StandardCharsets.UTF_8);
+  }
+
+  private static Object readJson(ByteBuffer buffer) {
+    throw new UnsupportedOperationException();
+  }
+
+  private static Object readDateTime(ByteBuffer buffer) {
+    throw new UnsupportedOperationException();
+  }
+
+  private static Object readBinary(ByteBuffer buffer) {
+    throw new UnsupportedOperationException();
+  }
+
+  private static class BoundedInputStream extends InputStream {
+    private final InputStream in;
+    private long remaining;
+
+    private BoundedInputStream(InputStream in, long size) {
+      this.in = in;
+      this.remaining = size;
+    }
+
+    @Override
+    public int read() throws IOException {
+      if (remaining == 0) {
+        return -1;
+      }
+      int result = in.read();
+      if (result != -1) {
+        remaining--;
+      }
+      return result;
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+      if (remaining == 0) {
+        return -1;
+      }
+      int toRead = (int) Math.min(len, remaining);
+      int result = in.read(b, off, toRead);
+      if (result != -1) {
+        remaining -= result;
+      }
+      return result;
+    }
+
+    @Override
+    public long skip(long n) throws IOException {
+      long toSkip = Math.min(n, remaining);
+      long skipped = in.skip(toSkip);
+      remaining -= skipped;
+      return skipped;
+    }
+
+    @Override
+    public int available() throws IOException {
+      return (int) Math.min(in.available(), remaining);
+    }
+
+    @Override
+    public void close() throws IOException {
+      in.close();
+    }
+  }
+
+  public static void skipIndex(ReadableByteChannel channel, Header header)
+      throws IOException {
+    long n = PackedRTree.calcSize(header.featuresCount(), header.indexNodeSize());
+
+    // Define a buffer size for skipping bytes
+    int bufferSize = 1 << 10;
+    ByteBuffer buffer = ByteBuffer.allocate(bufferSize);
+
+    // Number of bytes left to skip
+    long remaining = n;
+
+    while (remaining > 0) {
+      // Calculate how many bytes to read in this iteration
+      int bytesToRead = (int) Math.min(bufferSize, remaining);
+
+      // Set the buffer limit to the number of bytes to read
+      buffer.limit(bytesToRead);
+
+      // Read bytes into the buffer
+      int bytesRead = channel.read(buffer);
+
+      // Check if end of stream is reached
+      if (bytesRead == -1) {
+        throw new IOException("End of stream reached before skipping the required number of bytes");
+      }
+
+      // Update the remaining bytes to skip
+      remaining -= bytesRead;
+
+      // Clear the buffer for the next read
+      buffer.clear();
+    }
+  }
+
+  public static ByteBuffer readIndexBuffer(ReadableByteChannel channel, Header header)
+      throws IOException {
+
+    // Calculate the size of the index
+    long indexSize = PackedRTree.calcSize(header.featuresCount(), header.indexNodeSize());
+    if (indexSize > 1L << 31) {
+      throw new IOException("Index size is greater than 2GB!");
+    }
+
+    // Read the index
+    ByteBuffer buffer = ByteBuffer.allocate((int) indexSize).order(ByteOrder.LITTLE_ENDIAN);
+    while (buffer.hasRemaining()) {
+      if (channel.read(buffer) == -1) {
+        break; // End of channel reached
+      }
+    }
+
+    // Prepare the buffer for reading
+    buffer.flip();
+    return buffer;
+  }
+
+  public static InputStream readIndexStream(ReadableByteChannel channel, Header header) {
+    long indexSize = PackedRTree.calcSize(header.featuresCount(), header.indexNodeSize());
+    return new BoundedInputStream(Channels.newInputStream(channel), indexSize);
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBufWriter.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBufWriter.java
new file mode 100644
index 00000000..469740f4
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/FlatGeoBufWriter.java
@@ -0,0 +1,292 @@
+/*
+ * 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.baremaps.flatgeobuf;
+
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.Channels;
+import java.nio.channels.WritableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.baremaps.flatgeobuf.generated.*;
+import org.locationtech.jts.geom.Geometry;
+
+/**
+ * This class contains the logic for writing FlatGeoBuf files. It can either write FlatBuffers
+ * directly or write the domain model objects.
+ * <p>
+ * This code has been adapted from FlatGeoBuf (BSD 2-Clause "Simplified" License).
+ * <p>
+ * Copyright (c) 2018, Björn Harrtell
+ */
+public class FlatGeoBufWriter implements AutoCloseable {
+
+  private final WritableByteChannel channel;
+
+  private Header header;
+
+  public FlatGeoBufWriter(WritableByteChannel channel) {
+    this.channel = channel;
+  }
+
+  public void writeHeaderBuffer(Header header) throws IOException {
+    writeHeaderBuffer(channel, header);
+  }
+
+  public void writeHeader(FlatGeoBuf.Header header) throws IOException {
+    this.header = asFlatBuffer(header);
+    writeHeader(channel, header);
+  }
+
+  public void writeIndexStream(InputStream inputStream) throws IOException {
+    writeIndexStream(channel, inputStream);
+  }
+
+  public void writeIndexBuffer(ByteBuffer buffer) throws IOException {
+    writeIndexBuffer(channel, buffer);
+  }
+
+  public void writeFeatureBuffer(Feature feature) throws IOException {
+    writeFeatureBuffer(channel, feature);
+  }
+
+  public void writeFeature(FlatGeoBuf.Feature feature) throws IOException {
+    writeFeature(channel, header, feature);
+  }
+
+  @Override
+  public void close() throws IOException {
+    channel.close();
+  }
+
+  public static void writeHeaderBuffer(WritableByteChannel channel, Header header)
+      throws IOException {
+    ByteBuffer headerBuffer = header.getByteBuffer();
+    ByteBuffer startBuffer = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
+    startBuffer.put(FlatGeoBuf.MAGIC_BYTES);
+    startBuffer.putInt(headerBuffer.remaining());
+    startBuffer.flip();
+    while (startBuffer.hasRemaining()) {
+      channel.write(startBuffer);
+    }
+    while (headerBuffer.hasRemaining()) {
+      channel.write(headerBuffer);
+    }
+  }
+
+  public static void writeHeader(WritableByteChannel channel, FlatGeoBuf.Header header)
+      throws IOException {
+    Header headerFlatGeoBuf = asFlatBuffer(header);
+    writeHeaderBuffer(channel, headerFlatGeoBuf);
+  }
+
+  public static Header asFlatBuffer(FlatGeoBuf.Header header) {
+    FlatBufferBuilder builder = new FlatBufferBuilder(4096);
+
+    int[] columnsArray = header.columns().stream().mapToInt(c -> {
+      int nameOffset = builder.createString(c.name());
+      int type = c.type().ordinal();
+      return Column.createColumn(
+          builder, nameOffset, type, 0, 0, c.width(), c.precision(), c.scale(), c.nullable(),
+          c.unique(),
+          c.primaryKey(), 0);
+    }).toArray();
+    int columnsOffset =
+        Header.createColumnsVector(builder, columnsArray);
+
+    int envelopeOffset = 0;
+    if (header.envelope() != null) {
+      envelopeOffset = Header.createEnvelopeVector(builder,
+          header.envelope().stream().mapToDouble(d -> d).toArray());
+    }
+
+    int nameOffset = 0;
+    if (header.name() != null) {
+      nameOffset = builder.createString(header.name());
+    }
+
+    int crsOrgOffset = 0;
+    if (header.crs() != null && header.crs().org() != null) {
+      crsOrgOffset = builder.createString(header.crs().org());
+    }
+
+    int crsNameOffset = 0;
+    if (header.crs() != null && header.crs().name() != null) {
+      crsNameOffset = builder.createString(header.crs().name());
+    }
+
+    int crsDescriptionOffset = 0;
+    if (header.crs() != null && header.crs().description() != null) {
+      crsDescriptionOffset = builder.createString(header.crs().description());
+    }
+
+    int crsWktOffset = 0;
+    if (header.crs() != null && header.crs().wkt() != null) {
+      crsWktOffset = builder.createString(header.crs().wkt());
+    }
+
+    int crsCodeStringOffset = 0;
+    if (header.crs() != null && header.crs().codeString() != null) {
+      crsCodeStringOffset = builder.createString(header.crs().codeString());
+    }
+
+    Crs.startCrs(builder);
+    Crs.addOrg(builder, crsOrgOffset);
+    if (header.crs() != null) {
+      Crs.addCode(builder, header.crs().code());
+    }
+    Crs.addName(builder, crsNameOffset);
+    Crs.addDescription(builder, crsDescriptionOffset);
+    Crs.addWkt(builder, crsWktOffset);
+    Crs.addCodeString(builder, crsCodeStringOffset);
+    int crsOffset = Crs.endCrs(builder);
+
+    Header.startHeader(builder);
+    Header.addGeometryType(builder, header.geometryType().getValue());
+    Header.addFeaturesCount(builder, header.featuresCount());
+    Header.addIndexNodeSize(builder, header.indexNodeSize());
+    Header.addColumns(builder, columnsOffset);
+    Header.addEnvelope(builder, envelopeOffset);
+    Header.addName(builder, nameOffset);
+    Header.addCrs(builder, crsOffset);
+
+    int offset = Header.endHeader(builder);
+    builder.finish(offset);
+
+    ByteBuffer buffer = builder.dataBuffer().asReadOnlyBuffer();
+    return Header.getRootAsHeader(buffer);
+  }
+
+
+  public static void writeFeatureBuffer(WritableByteChannel channel, Feature feature)
+      throws IOException {
+    ByteBuffer sizeBuffer = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN);
+    sizeBuffer.putInt(feature.getByteBuffer().remaining());
+    sizeBuffer.flip();
+    while (sizeBuffer.hasRemaining()) {
+      channel.write(sizeBuffer);
+    }
+    ByteBuffer featureBuffer = feature.getByteBuffer().duplicate();
+    while (featureBuffer.hasRemaining()) {
+      channel.write(featureBuffer);
+    }
+  }
+
+  public static void writeFeature(
+      WritableByteChannel channel,
+      Header header,
+      FlatGeoBuf.Feature feature) throws IOException {
+    Feature flatBuffer = asFlatBuffer(header, feature);
+    writeFeatureBuffer(channel, flatBuffer);
+  }
+
+  public static Feature asFlatBuffer(Header header, FlatGeoBuf.Feature feature) {
+    FlatBufferBuilder builder = new FlatBufferBuilder(4096);
+
+    // Write the properties
+    ByteBuffer propertiesBuffer = ByteBuffer.allocate(1 << 20).order(ByteOrder.LITTLE_ENDIAN);
+    List<Object> properties = feature.properties();
+    for (int i = 0; i < properties.size(); i++) {
+      var column = header.columns(i);
+      var value = properties.get(i);
+      propertiesBuffer.putShort((short) i);
+      writeValue(propertiesBuffer, column, value);
+    }
+    if (propertiesBuffer.position() > 0) {
+      propertiesBuffer.flip();
+    }
+    int propertiesOffset = Feature.createPropertiesVector(builder, propertiesBuffer);
+
+    // Write the geometry
+    Geometry geometry = feature.geometry();
+    int geometryOffset = 0;
+    if (geometry != null) {
+      geometryOffset =
+          GeometryConversions.writeGeometry(builder, geometry,
+              (byte) header.geometryType());
+    }
+
+    // Write the feature
+    Feature.startFeature(builder);
+    Feature.addGeometry(builder, geometryOffset);
+    Feature.addProperties(builder, propertiesOffset);
+    Feature.addColumns(builder, 0);
+
+    int offset = Feature.endFeature(builder);
+    builder.finish(offset);
+
+    ByteBuffer buffer = builder.dataBuffer().asReadOnlyBuffer();
+    return Feature.getRootAsFeature(buffer);
+  }
+
+  private static void writeValue(ByteBuffer buffer, Column column, Object value) {
+    switch (column.type()) {
+      case ColumnType.Bool -> buffer.put((byte) ((boolean) value ? 1 : 0));
+      case ColumnType.Short -> buffer.putShort((short) value);
+      case ColumnType.UShort -> buffer.putShort((short) value);
+      case ColumnType.Int -> buffer.putInt((int) value);
+      case ColumnType.UInt -> buffer.putInt((int) value);
+      case ColumnType.Long -> buffer.putLong((long) value);
+      case ColumnType.ULong -> buffer.putLong((long) value);
+      case ColumnType.Float -> buffer.putFloat((float) value);
+      case ColumnType.Double -> buffer.putDouble((double) value);
+      case ColumnType.String -> writeString(buffer, value);
+      case ColumnType.Json -> writeJson(buffer, value);
+      case ColumnType.DateTime -> writeDateTime(buffer, value);
+      case ColumnType.Binary -> writeBinary(buffer, value);
+      default -> throw new UnsupportedOperationException();
+    }
+  }
+
+  private static void writeString(ByteBuffer buffer, Object value) {
+    var bytes = ((String) value).getBytes(StandardCharsets.UTF_8);
+    buffer.putInt(bytes.length);
+    buffer.put(bytes);
+  }
+
+  private static void writeJson(ByteBuffer buffer, Object value) {
+    throw new UnsupportedOperationException();
+  }
+
+  private static void writeDateTime(ByteBuffer buffer, Object value) {
+    throw new UnsupportedOperationException();
+  }
+
+  private static void writeBinary(ByteBuffer buffer, Object value) {
+    throw new UnsupportedOperationException();
+  }
+
+  public static void writeIndexStream(WritableByteChannel channel, InputStream inputStream)
+      throws IOException {
+    try (OutputStream outputStream = Channels.newOutputStream(channel)) {
+      outputStream.write(inputStream.readAllBytes());
+    }
+  }
+
+  public static void writeIndexBuffer(WritableByteChannel channel, ByteBuffer buffer)
+      throws IOException {
+    while (buffer.hasRemaining()) {
+      channel.write(buffer);
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/GeometryConversions.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/GeometryConversions.java
new file mode 100644
index 00000000..1951c005
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/GeometryConversions.java
@@ -0,0 +1,454 @@
+/*
+ * 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.baremaps.flatgeobuf;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+import java.util.Arrays;
+import java.util.function.IntFunction;
+import java.util.function.Supplier;
+import org.apache.baremaps.flatgeobuf.generated.Geometry;
+import org.apache.baremaps.flatgeobuf.generated.GeometryType;
+import org.locationtech.jts.geom.*;
+
+/**
+ * This code has been adapted from FlatGeoBuf (BSD 2-Clause "Simplified" License).
+ * <p>
+ * Copyright (c) 2018, Björn Harrtell
+ */
+public class GeometryConversions {
+
+  private static final String UNKNOWN_GEOMETRY_TYPE = "Unknown geometry type";
+
+  private GeometryConversions() {
+    // Prevent instantiation
+  }
+
+  @SuppressWarnings("squid:S3776")
+  public static GeometryOffsets writeGeometryPart(
+      FlatBufferBuilder builder,
+      org.locationtech.jts.geom.Geometry geometry,
+      int geometryType) {
+
+    GeometryOffsets go = new GeometryOffsets();
+
+    if (geometry == null) {
+      return go;
+    }
+
+    if (geometryType == GeometryType.MultiLineString) {
+      int end = 0;
+      MultiLineString mls = (MultiLineString) geometry;
+      if (mls.getNumGeometries() > 1) {
+        go.ends = new long[mls.getNumGeometries()];
+        for (int i = 0; i < mls.getNumGeometries(); i++) {
+          end += mls.getGeometryN(i).getNumPoints();
+          go.ends[i] = end;
+        }
+      }
+    } else if (geometryType == GeometryType.Polygon) {
+      Polygon p = (Polygon) geometry;
+      go.ends = new long[p.getNumInteriorRing() + 1];
+      int end = p.getExteriorRing().getNumPoints();
+      go.ends[0] = end;
+      for (int i = 0; i < p.getNumInteriorRing(); i++) {
+        end += p.getInteriorRingN(i).getNumPoints();
+        go.ends[i + 1] = end;
+      }
+    } else if (geometryType == GeometryType.MultiPolygon) {
+      MultiPolygon mp = (MultiPolygon) geometry;
+      int numGeometries = mp.getNumGeometries();
+      GeometryOffsets[] gos = new GeometryOffsets[numGeometries];
+      for (int i = 0; i < numGeometries; i++) {
+        Polygon p = (Polygon) mp.getGeometryN(i);
+        gos[i] = writeGeometryPart(builder, p, GeometryType.Polygon);
+      }
+      go.gos = gos;
+      return go;
+    }
+
+    final int numPoints = geometry.getNumPoints();
+    // build the vector "manually", using a CoordinateSequenceFilter to avoid creating
+    // Coordinate arrays or any Coordinate at all, depending on the underlying
+    // CoordinateSequence implementation. Vector elements ought to be added in reverse order
+    Geometry.startXyVector(builder, 2 * numPoints);
+    ReverseXYCoordinateSequenceFilter filter = new ReverseXYCoordinateSequenceFilter(builder);
+    applyInReverseOrder(geometry, filter);
+    go.coordsOffset = builder.endVector();
+
+    if (filter.hasZ) {
+      Geometry.startZVector(builder, numPoints);
+      applyInReverseOrder(geometry, new OrdinateCoordinateSequenceFilter(builder, 2));
+      go.zOffset = builder.endVector();
+    } else {
+      go.zOffset = 0;
+    }
+
+    if (filter.hasM) {
+      Geometry.startMVector(builder, numPoints);
+      applyInReverseOrder(geometry, new OrdinateCoordinateSequenceFilter(builder, 3));
+      go.mOffset = builder.endVector();
+    } else {
+      go.mOffset = 0;
+    }
+
+    if (go.ends != null) {
+      go.endsOffset = Geometry.createEndsVector(builder, go.ends);
+    }
+
+    go.type = geometryType;
+
+    return go;
+  }
+
+  public static int writeGeometry(FlatBufferBuilder builder,
+      org.locationtech.jts.geom.Geometry geometry, byte geometryType) {
+    byte knownGeometryType = geometryType;
+    if (geometryType == GeometryType.Unknown) {
+      knownGeometryType = GeometryConversions.toGeometryType(geometry.getClass());
+    }
+
+    GeometryOffsets go =
+        GeometryConversions.writeGeometryPart(builder, geometry, knownGeometryType);
+
+    int geometryOffset;
+    if (go.gos != null && go.gos.length > 0) {
+      int[] partOffsets = new int[go.gos.length];
+      for (int i = 0; i < go.gos.length; i++) {
+        GeometryOffsets goPart = go.gos[i];
+        int partOffset = Geometry.createGeometry(builder, goPart.endsOffset,
+            goPart.coordsOffset, goPart.zOffset, goPart.mOffset, 0, 0, goPart.type, 0);
+        partOffsets[i] = partOffset;
+      }
+      int partsOffset = Geometry.createPartsVector(builder, partOffsets);
+      geometryOffset = Geometry.createGeometry(builder, 0, 0, 0, 0, 0, 0,
+          geometryType == GeometryType.Unknown ? knownGeometryType : 0, partsOffset);
+    } else {
+      geometryOffset =
+          Geometry.createGeometry(builder, go.endsOffset, go.coordsOffset, go.zOffset, go.mOffset,
+              0, 0, geometryType == GeometryType.Unknown ? knownGeometryType : 0, 0);
+    }
+    return geometryOffset;
+  }
+
+  /**
+   * Applies the {@code filter} to all {@link org.locationtech.jts.geom.Geometry#getGeometryN(int)
+   * subgeometries} in reverse order if it's a {@link GeometryCollection} or a {@link Polygon} (i.e.
+   * interior rings in reverse order first)
+   */
+  private static void applyInReverseOrder(org.locationtech.jts.geom.Geometry geometry,
+      CoordinateSequenceFilter filter) {
+
+    final int numGeometries = geometry.getNumGeometries();
+    if (numGeometries > 1) {
+      for (int i = numGeometries - 1; i >= 0; i--) {
+        org.locationtech.jts.geom.Geometry sub = geometry.getGeometryN(i);
+        applyInReverseOrder(sub, filter);
+      }
+    } else if (geometry instanceof Polygon polygon) {
+      for (int i = polygon.getNumInteriorRing() - 1; i >= 0; i--) {
+        org.locationtech.jts.geom.Geometry hole = polygon.getInteriorRingN(i);
+        applyInReverseOrder(hole, filter);
+      }
+      applyInReverseOrder(polygon.getExteriorRing(), filter);
+    } else {
+      geometry.apply(filter);
+    }
+  }
+
+  private static class OrdinateCoordinateSequenceFilter implements CoordinateSequenceFilter {
+    private FlatBufferBuilder builder;
+    private final int ordinateIndex;
+
+    OrdinateCoordinateSequenceFilter(FlatBufferBuilder builder, int ordinateIndex) {
+      this.builder = builder;
+      this.ordinateIndex = ordinateIndex;
+    }
+
+    public @Override void filter(final CoordinateSequence seq, final int coordIndex) {
+      int reverseSeqIndex = seq.size() - coordIndex - 1;
+      builder.addDouble(seq.getOrdinate(reverseSeqIndex, ordinateIndex));
+    }
+
+    public @Override boolean isGeometryChanged() {
+      return false;
+    }
+
+    public @Override boolean isDone() {
+      return false;
+    }
+  }
+
+  private static class ReverseXYCoordinateSequenceFilter implements CoordinateSequenceFilter {
+    private FlatBufferBuilder builder;
+    boolean hasZ = false;
+    boolean hasM = false;
+
+    ReverseXYCoordinateSequenceFilter(FlatBufferBuilder builder) {
+      this.builder = builder;
+    }
+
+    public @Override void filter(final CoordinateSequence seq, final int coordIndex) {
+      int reverseSeqIndex = seq.size() - coordIndex - 1;
+      double y = seq.getOrdinate(reverseSeqIndex, 1);
+      double x = seq.getOrdinate(reverseSeqIndex, 0);
+      builder.addDouble(y);
+      builder.addDouble(x);
+      if (!hasZ && seq.hasZ()) {
+        hasZ = true;
+      }
+      if (!hasM && seq.hasM()) {
+        hasM = true;
+      }
+    }
+
+    public boolean isHasZ() {
+      return hasZ;
+    }
+
+    public boolean isHasM() {
+      return hasM;
+    }
+
+    public @Override boolean isGeometryChanged() {
+      return false;
+    }
+
+    public @Override boolean isDone() {
+      return false;
+    }
+  }
+
+  @SuppressWarnings("squid:S3776")
+  public static org.locationtech.jts.geom.Geometry readGeometry(Geometry geometry,
+      int geometryType) {
+    GeometryFactory factory = new GeometryFactory();
+
+    if (geometryType == GeometryType.MultiPolygon) {
+      int partsLength = geometry.partsLength();
+      Polygon[] polygons = new Polygon[partsLength];
+      for (int i = 0; i < geometry.partsLength(); i++) {
+        polygons[i] = (Polygon) readGeometry(geometry.parts(i), GeometryType.Polygon);
+      }
+      return factory.createMultiPolygon(polygons);
+    }
+
+    int xyLength = geometry.xyLength();
+
+    Coordinate[] coordinates = new Coordinate[xyLength >> 1];
+
+    int c = 0;
+    for (int i = 0; i < xyLength; i = i + 2) {
+      if (c < geometry.mLength()) {
+        coordinates[c++] = new CoordinateXYZM(geometry.xy(i), geometry.xy(i + 1),
+            (i >> 1) < geometry.zLength() ? geometry.z((i >> 1)) : Coordinate.NULL_ORDINATE,
+            (i >> 1) < geometry.mLength() ? geometry.m((i >> 1)) : Coordinate.NULL_ORDINATE);
+      } else {
+        coordinates[c++] = new Coordinate(geometry.xy(i), geometry.xy(i + 1),
+            (i >> 1) < geometry.zLength() ? geometry.z((i >> 1)) : Coordinate.NULL_ORDINATE);
+      }
+    }
+
+    IntFunction<Polygon> makePolygonWithRings = (int endsLength) -> {
+      LinearRing[] lrs = new LinearRing[endsLength];
+      int s = 0;
+      for (int i = 0; i < endsLength; i++) {
+        int e = (int) geometry.ends(i);
+        Coordinate[] cs = Arrays.copyOfRange(coordinates, s, e);
+        lrs[i] = factory.createLinearRing(cs);
+        s = e;
+      }
+      LinearRing shell = lrs[0];
+      LinearRing[] holes = Arrays.copyOfRange(lrs, 1, endsLength);
+      return factory.createPolygon(shell, holes);
+    };
+
+    Supplier<Polygon> makePolygon = () -> {
+      int endsLength = geometry.endsLength();
+      if (endsLength > 1) {
+        return makePolygonWithRings.apply(endsLength);
+      } else {
+        return factory.createPolygon(coordinates);
+      }
+    };
+
+    switch (geometryType) {
+      case GeometryType.Unknown:
+        return null;
+      case GeometryType.Point:
+        if (coordinates.length > 0) {
+          return factory.createPoint(coordinates[0]);
+        } else {
+          return factory.createPoint();
+        }
+      case GeometryType.MultiPoint:
+        return factory.createMultiPointFromCoords(coordinates);
+      case GeometryType.LineString:
+        return factory.createLineString(coordinates);
+      case GeometryType.MultiLineString: {
+        int lengthLengths = geometry.endsLength();
+        if (lengthLengths < 2) {
+          return factory
+              .createMultiLineString(new LineString[] {factory.createLineString(coordinates)});
+        }
+        LineString[] lss = new LineString[lengthLengths];
+        int s = 0;
+        for (int i = 0; i < lengthLengths; i++) {
+          int e = (int) geometry.ends(i);
+          Coordinate[] cs = Arrays.copyOfRange(coordinates, s, e);
+          lss[i] = factory.createLineString(cs);
+          s = e;
+        }
+        return factory.createMultiLineString(lss);
+      }
+      case GeometryType.Polygon:
+        return makePolygon.get();
+      default:
+        throw new IllegalArgumentException(UNKNOWN_GEOMETRY_TYPE);
+    }
+  }
+
+  public static Class<?> getGeometryClass(int geometryType) {
+    switch (geometryType) {
+      case GeometryType.Unknown:
+        return Geometry.class;
+      case GeometryType.Point:
+        return Point.class;
+      case GeometryType.MultiPoint:
+        return MultiPoint.class;
+      case GeometryType.LineString:
+        return LineString.class;
+      case GeometryType.MultiLineString:
+        return MultiLineString.class;
+      case GeometryType.Polygon:
+        return Polygon.class;
+      case GeometryType.MultiPolygon:
+        return MultiPolygon.class;
+      default:
+        throw new IllegalArgumentException(UNKNOWN_GEOMETRY_TYPE);
+    }
+  }
+
+  public static byte toGeometryType(Class<?> geometryClass) {
+    if (geometryClass == org.locationtech.jts.geom.Geometry.class) {
+      return GeometryType.Unknown;
+    } else if (MultiPoint.class.isAssignableFrom(geometryClass)) {
+      return GeometryType.MultiPoint;
+    } else if (Point.class.isAssignableFrom(geometryClass)) {
+      return GeometryType.Point;
+    } else if (MultiLineString.class.isAssignableFrom(geometryClass)) {
+      return GeometryType.MultiLineString;
+    } else if (LineString.class.isAssignableFrom(geometryClass)) {
+      return GeometryType.LineString;
+    } else if (MultiPolygon.class.isAssignableFrom(geometryClass)) {
+      return GeometryType.MultiPolygon;
+    } else if (Polygon.class.isAssignableFrom(geometryClass)) {
+      return GeometryType.Polygon;
+    } else {
+      throw new IllegalArgumentException(UNKNOWN_GEOMETRY_TYPE);
+    }
+  }
+
+  public static class GeometryOffsets {
+
+    private int coordsOffset;
+    private int zOffset;
+    private int mOffset;
+    private long[] ends = null;
+    private int[] lengths = null;
+    private int endsOffset = 0;
+    private int lengthsOffset = 0;
+    private int type = 0;
+    private GeometryOffsets[] gos = null;
+
+    public GeometryOffsets() {
+      // Default constructor
+    }
+
+    public int getCoordsOffset() {
+      return coordsOffset;
+    }
+
+    public int getzOffset() {
+      return zOffset;
+    }
+
+    public int getmOffset() {
+      return mOffset;
+    }
+
+    public long[] getEnds() {
+      return ends;
+    }
+
+    public int[] getLengths() {
+      return lengths;
+    }
+
+    public int getEndsOffset() {
+      return endsOffset;
+    }
+
+    public int getLengthsOffset() {
+      return lengthsOffset;
+    }
+
+    public int getType() {
+      return type;
+    }
+
+    public GeometryOffsets[] getGos() {
+      return gos;
+    }
+
+    public void setCoordsOffset(int coordsOffset) {
+      this.coordsOffset = coordsOffset;
+    }
+
+    public void setzOffset(int zOffset) {
+      this.zOffset = zOffset;
+    }
+
+    public void setmOffset(int mOffset) {
+      this.mOffset = mOffset;
+    }
+
+    public void setEnds(long[] ends) {
+      this.ends = ends;
+    }
+
+    public void setLengths(int[] lengths) {
+      this.lengths = lengths;
+    }
+
+    public void setEndsOffset(int endsOffset) {
+      this.endsOffset = endsOffset;
+    }
+
+    public void setLengthsOffset(int lengthsOffset) {
+      this.lengthsOffset = lengthsOffset;
+    }
+
+    public void setType(int type) {
+      this.type = type;
+    }
+
+    public void setGos(GeometryOffsets[] gos) {
+      this.gos = gos;
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/PackedRTree.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/PackedRTree.java
new file mode 100644
index 00000000..4c3bf63b
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/PackedRTree.java
@@ -0,0 +1,508 @@
+/*
+ * 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.baremaps.flatgeobuf;
+
+import com.google.common.io.LittleEndianDataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.*;
+import org.apache.baremaps.flatgeobuf.generated.Header;
+import org.locationtech.jts.geom.Envelope;
+
+/**
+ * This code has been adapted from FlatGeoBuf (BSD 2-Clause "Simplified" License).
+ * <p>
+ * Copyright (c) 2018, Björn Harrtell
+ */
+@SuppressWarnings("squid:S117")
+public class PackedRTree {
+
+  private static final int HILBERT_MAX = (1 << 16) - 1;
+  private static final int NODE_ITEM_LEN = 8 * 4 + 8;
+  private static final String ILLEGAL_NODE_SIZE = "Node size must be at least 2";
+  private static final String ILLEGAL_NUMBER_OF_ITEMS = "Number of items must be greater than 0";
+  private int numItems;
+  private int nodeSize;
+  private NodeItem[] nodeItems;
+  private long numNodes;
+  private List<Pair<Integer, Integer>> levelBounds;
+
+  public PackedRTree(final List<Item> items, final short nodeSize) {
+    this.numItems = items.size();
+    init(nodeSize);
+    int k = (int) (this.numNodes - this.numItems);
+    Iterator<Item> it = items.iterator();
+    for (int i = 0; i < this.numItems; ++i) {
+      this.nodeItems[k++] = it.next().nodeItem();
+    }
+    generateNodes();
+  }
+
+  public void init(int nodeSize) {
+    if (nodeSize < 2) {
+      throw new IllegalArgumentException(ILLEGAL_NODE_SIZE);
+    }
+    if (numItems == 0) {
+      throw new IllegalArgumentException(ILLEGAL_NUMBER_OF_ITEMS);
+    }
+    this.nodeSize = Math.min(nodeSize, HILBERT_MAX);
+    this.levelBounds = generateLevelBounds(numItems, this.nodeSize);
+    this.numNodes = levelBounds.get(0).second;
+    this.nodeItems = new NodeItem[Math.toIntExact(numNodes)];
+  }
+
+  void generateNodes() {
+    long pos;
+    long end;
+    for (int i = 0; i < levelBounds.size() - 1; i++) {
+      pos = levelBounds.get(i).first;
+      end = levelBounds.get(i).second;
+      long newpos = levelBounds.get(i + 1).first;
+      while (pos < end) {
+        NodeItem node = new NodeItem(pos);
+        for (long j = 0; j < this.nodeSize && pos < end; j++) {
+          node.expand(nodeItems[(int) pos++]);
+        }
+        nodeItems[(int) newpos++] = node;
+      }
+    }
+  }
+
+  public static List<Item> hilbertSort(List<Item> items, NodeItem extent) {
+    double minX = extent.minX();
+    double minY = extent.minY();
+    double width = extent.width();
+    double height = extent.height();
+    items.sort((a, b) -> {
+      long ha = hibert(a.nodeItem, HILBERT_MAX, minX, minY, width, height);
+      long hb = hibert(b.nodeItem, HILBERT_MAX, minX, minY, width, height);
+      long delta = ha - hb;
+      if (delta > 0) {
+        return 1;
+      } else if (delta == 0) {
+        return 0;
+      } else {
+        return -1;
+      }
+    });
+    return items;
+  }
+
+  public static long hibert(NodeItem nodeItem, int hilbertMax, double minX, double minY,
+      double width, double height) {
+    long x = 0;
+    long y = 0;
+    if (width != 0.0) {
+      x = (long) Math.floor(hilbertMax * ((nodeItem.minX() + nodeItem.maxX()) / 2 - minX) / width);
+    }
+    if (height != 0.0) {
+      y = (long) Math.floor(hilbertMax * ((nodeItem.minY() + nodeItem.maxY()) / 2 - minY) / height);
+    }
+    return hibert(x, y);
+  }
+
+  // Based on public domain code at https://github.com/rawrunprotected/hilbert_curves
+  private static long hibert(long x, long y) {
+    long a = x ^ y;
+    long b = 0xFFFF ^ a;
+    long c = 0xFFFF ^ (x | y);
+    long d = x & (y ^ 0xFFFF);
+    long A = a | (b >> 1);
+    long B = (a >> 1) ^ a;
+    long C = ((c >> 1) ^ (b & (d >> 1))) ^ c;
+    long D = ((a & (c >> 1)) ^ (d >> 1)) ^ d;
+
+    a = A;
+    b = B;
+    c = C;
+    d = D;
+    A = ((a & (a >> 2)) ^ (b & (b >> 2)));
+    B = ((a & (b >> 2)) ^ (b & ((a ^ b) >> 2)));
+    C ^= ((a & (c >> 2)) ^ (b & (d >> 2)));
+    D ^= ((b & (c >> 2)) ^ ((a ^ b) & (d >> 2)));
+
+    a = A;
+    b = B;
+    c = C;
+    d = D;
+    A = ((a & (a >> 4)) ^ (b & (b >> 4)));
+    B = ((a & (b >> 4)) ^ (b & ((a ^ b) >> 4)));
+    C ^= ((a & (c >> 4)) ^ (b & (d >> 4)));
+    D ^= ((b & (c >> 4)) ^ ((a ^ b) & (d >> 4)));
+
+    a = A;
+    b = B;
+    c = C;
+    d = D;
+    C ^= ((a & (c >> 8)) ^ (b & (d >> 8)));
+    D ^= ((b & (c >> 8)) ^ ((a ^ b) & (d >> 8)));
+
+    a = C ^ (C >> 1);
+    b = D ^ (D >> 1);
+
+    long i0 = x ^ y;
+    long i1 = b | (0xFFFF ^ (i0 | a));
+
+    i0 = (i0 | (i0 << 8)) & 0x00FF00FF;
+    i0 = (i0 | (i0 << 4)) & 0x0F0F0F0F;
+    i0 = (i0 | (i0 << 2)) & 0x33333333;
+    i0 = (i0 | (i0 << 1)) & 0x55555555;
+
+    i1 = (i1 | (i1 << 8)) & 0x00FF00FF;
+    i1 = (i1 | (i1 << 4)) & 0x0F0F0F0F;
+    i1 = (i1 | (i1 << 2)) & 0x33333333;
+    i1 = (i1 | (i1 << 1)) & 0x55555555;
+
+    return ((i1 << 1) | i0);
+  }
+
+  public static NodeItem calcExtent(List<Item> items) {
+    return items.stream()
+        .map(Item::nodeItem)
+        .reduce(new NodeItem(0), NodeItem::expand);
+  }
+
+  public void write(OutputStream outputStream) throws IOException {
+    // nodeItem 40 Byte
+    ByteBuffer buffer = ByteBuffer.allocate((int) (NODE_ITEM_LEN * numNodes));
+    buffer.order(ByteOrder.LITTLE_ENDIAN);
+    for (NodeItem nodeItem : nodeItems) {
+      buffer.putDouble(nodeItem.minX());
+      buffer.putDouble(nodeItem.minY());
+      buffer.putDouble(nodeItem.maxX());
+      buffer.putDouble(nodeItem.maxY());
+      buffer.putLong(nodeItem.offset());
+    }
+    buffer.flip();
+    try {
+      if (buffer.hasRemaining()) {
+        byte[] arr = new byte[buffer.remaining()];
+        buffer.get(arr);
+        outputStream.write(arr);
+        outputStream.flush();
+      }
+    } finally {
+      buffer.clear();
+      buffer = null;
+    }
+  }
+
+  public static long calcSize(long numItems, int nodeSize) {
+    if (nodeSize < 2) {
+      throw new IllegalArgumentException(ILLEGAL_NODE_SIZE);
+    }
+    if (numItems == 0) {
+      throw new IllegalArgumentException(ILLEGAL_NUMBER_OF_ITEMS);
+    }
+    int nodeSizeMin = Math.min(nodeSize, 65535);
+    // limit so that resulting size in bytes can be represented by ulong
+    if (numItems > 1L << 56) {
+      throw new IndexOutOfBoundsException("Number of items must be less than 2^56");
+    }
+    long n = numItems;
+    long numNodes = n;
+    do {
+      n = (n + nodeSizeMin - 1) / nodeSizeMin;
+      numNodes += n;
+    } while (n != 1);
+    return numNodes * NODE_ITEM_LEN;
+  }
+
+  static List<Pair<Integer, Integer>> generateLevelBounds(int numItems, int nodeSize) {
+    if (nodeSize < 2) {
+      throw new IllegalArgumentException(ILLEGAL_NODE_SIZE);
+    }
+    if (numItems == 0) {
+      throw new IllegalArgumentException(ILLEGAL_NUMBER_OF_ITEMS);
+    }
+
+    // number of nodes per level in bottom-up order
+    int n = numItems;
+    int numNodes = n;
+    ArrayList<Integer> levelNumNodes = new ArrayList<Integer>();
+    levelNumNodes.add(n);
+    do {
+      n = (n + nodeSize - 1) / nodeSize;
+      numNodes += n;
+      levelNumNodes.add(n);
+    } while (n != 1);
+
+    // offsets per level in reversed storage order (top-down)
+    ArrayList<Integer> levelOffsets = new ArrayList<Integer>();
+    n = numNodes;
+    for (int size : levelNumNodes) {
+      n -= size;
+      levelOffsets.add(n);
+    }
+    List<Pair<Integer, Integer>> levelBounds = new LinkedList<>();
+    // bounds per level in reversed storage order (top-down)
+    for (int i = 0; i < levelNumNodes.size(); i++) {
+      levelBounds.add(new Pair<>(levelOffsets.get(i), levelOffsets.get(i) + levelNumNodes.get(i)));
+    }
+    return levelBounds;
+  }
+
+  public record QueueItem(long nodeIndex, int level) {
+
+  }
+
+  public record SearchHit(long offset, long index) {
+
+  }
+
+  @SuppressWarnings("squid:S3776")
+  public static List<SearchHit> search(
+      ByteBuffer bb,
+      int start,
+      int numItems,
+      int nodeSize,
+      Envelope rect) {
+    ArrayList<SearchHit> searchHits = new ArrayList<SearchHit>();
+    double minX = rect.getMinX();
+    double minY = rect.getMinY();
+    double maxX = rect.getMaxX();
+    double maxY = rect.getMaxY();
+    List<Pair<Integer, Integer>> levelBounds = generateLevelBounds(numItems, nodeSize);
+    int leafNodesOffset = levelBounds.get(0).first;
+    int numNodes = levelBounds.get(0).second;
+    Deque<QueueItem> queue = new LinkedList<QueueItem>();
+    queue.add(new QueueItem(0, levelBounds.size() - 1));
+    while (!queue.isEmpty()) {
+      QueueItem stackItem = queue.pop();
+      int nodeIndex = (int) stackItem.nodeIndex;
+      int level = stackItem.level;
+      boolean isLeafNode = nodeIndex >= numNodes - numItems;
+      // find the end index of the node
+      int levelEnd = levelBounds.get(level).second;
+      int end = Math.min(nodeIndex + nodeSize, levelEnd);
+      int nodeStart = start + (nodeIndex * NODE_ITEM_LEN);
+      // search through child nodes
+      for (int pos = nodeIndex; pos < end; pos++) {
+        int offset = nodeStart + ((pos - nodeIndex) * NODE_ITEM_LEN);
+        double nodeMinX = bb.getDouble(offset + 0);
+        double nodeMinY = bb.getDouble(offset + 8);
+        double nodeMaxX = bb.getDouble(offset + 16);
+        double nodeMaxY = bb.getDouble(offset + 24);
+        if (maxX < nodeMinX) {
+          continue;
+        }
+        if (maxY < nodeMinY) {
+          continue;
+        }
+        if (minX > nodeMaxX) {
+          continue;
+        }
+        if (minY > nodeMaxY) {
+          continue;
+        }
+        long indexOffset = bb.getLong(offset + 32);
+        if (isLeafNode) {
+          searchHits.add(new SearchHit(indexOffset, (long) pos - leafNodesOffset));
+        } else {
+          queue.add(new QueueItem(indexOffset, level - 1));
+        }
+      }
+    }
+    return searchHits;
+  }
+
+  public record SearchResult(List<SearchHit> hits, int pos) {
+
+  }
+
+  @SuppressWarnings("squid:S3776")
+  public static SearchResult search(
+      InputStream stream,
+      int start,
+      int numItems,
+      int nodeSize,
+      Envelope rect) throws IOException {
+    LittleEndianDataInputStream data = new LittleEndianDataInputStream(stream);
+    int dataPos = 0;
+    int skip;
+    List<SearchHit> hits = new ArrayList<>();
+    double minX = rect.getMinX();
+    double minY = rect.getMinY();
+    double maxX = rect.getMaxX();
+    double maxY = rect.getMaxY();
+    List<Pair<Integer, Integer>> levelBounds = generateLevelBounds(numItems, nodeSize);
+    int leafNodesOffset = levelBounds.get(0).first;
+    int numNodes = levelBounds.get(0).second;
+    Deque<QueueItem> queue = new LinkedList<QueueItem>();
+    queue.add(new QueueItem(0, levelBounds.size() - 1));
+    while (!queue.isEmpty()) {
+      QueueItem stackItem = queue.pop();
+      int nodeIndex = (int) stackItem.nodeIndex;
+      int level = stackItem.level;
+      boolean isLeafNode = nodeIndex >= numNodes - numItems;
+      // find the end index of the node
+      int levelBound = levelBounds.get(level).second;
+      int end = Math.min(nodeIndex + nodeSize, levelBound);
+      int nodeStart = nodeIndex * NODE_ITEM_LEN;
+      skip = nodeStart - dataPos;
+      if (skip > 0) {
+        skipNBytes(data, skip);
+        dataPos += skip;
+      }
+      // search through child nodes
+      for (int pos = nodeIndex; pos < end; pos++) {
+        int offset = nodeStart + ((pos - nodeIndex) * NODE_ITEM_LEN);
+        skip = offset - dataPos;
+        if (skip > 0) {
+          skipNBytes(data, skip);
+          dataPos += skip;
+        }
+        double nodeMinX = data.readDouble();
+        dataPos += 8;
+        if (maxX < nodeMinX) {
+          continue;
+        }
+        double nodeMinY = data.readDouble();
+        dataPos += 8;
+        if (maxY < nodeMinY) {
+          continue;
+        }
+        double nodeMaxX = data.readDouble();
+        dataPos += 8;
+        if (minX > nodeMaxX) {
+          continue;
+        }
+        double nodeMaxY = data.readDouble();
+        dataPos += 8;
+        if (minY > nodeMaxY) {
+          continue;
+        }
+        long indexOffset = data.readLong();
+        dataPos += 8;
+        if (isLeafNode) {
+          hits.add(new SearchHit(indexOffset, (long) pos - leafNodesOffset));
+        } else {
+          queue.add(new QueueItem(indexOffset, level - 1));
+        }
+      }
+    }
+    return new SearchResult(hits, dataPos);
+  }
+
+  public static long[] readFeatureOffsets(
+      LittleEndianDataInputStream data,
+      long[] fids,
+      Header header) throws IOException {
+
+    long treeSize = calcSize((int) header.featuresCount(), header.indexNodeSize());
+    List<Pair<Integer, Integer>> levelBounds =
+        generateLevelBounds((int) header.featuresCount(), header.indexNodeSize());
+    long bottomLevelOffset = levelBounds.get(0).first * 40L;
+
+    long pos = 0;
+    long[] featureOffsets = new long[fids.length];
+    for (int i = 0; i < fids.length; i++) {
+      if (fids[i] > header.featuresCount() - 1) {
+        throw new NoSuchElementException();
+      }
+      long nodeItemOffset = bottomLevelOffset + (fids[i] * 40);
+      long delta = nodeItemOffset + (8 * 4) - pos;
+      skipNBytes(data, delta);
+      long featureOffset = data.readLong();
+      pos += delta + 8;
+      featureOffsets[i] = featureOffset;
+    }
+    long remainingIndexOffset = treeSize - pos;
+    skipNBytes(data, remainingIndexOffset);
+
+    return featureOffsets;
+  }
+
+  static void skipNBytes(InputStream stream, long skip) throws IOException {
+    long remaining = skip;
+    while (remaining > 0) {
+      remaining -= stream.skip(remaining);
+    }
+  }
+
+  public record Item(NodeItem nodeItem) {
+
+  }
+
+  public record Pair<T, U> (T first, U second) {
+
+  }
+
+  public record NodeItem(
+      double minX,
+      double minY,
+      double maxX,
+      double maxY,
+      long offset) {
+
+    public NodeItem(double minX, double minY, double maxX, double maxY) {
+      this(minX, minY, maxX, maxY, 0);
+    }
+
+    public NodeItem(long offset) {
+      this(
+          Double.POSITIVE_INFINITY,
+          Double.POSITIVE_INFINITY,
+          Double.NEGATIVE_INFINITY,
+          Double.NEGATIVE_INFINITY,
+          offset);
+    }
+
+    public double width() {
+      return maxX - minX;
+    }
+
+    public double height() {
+      return maxY - minY;
+    }
+
+    public static NodeItem sum(NodeItem a, final NodeItem b) {
+      return a.expand(b);
+    }
+
+    public NodeItem expand(final NodeItem nodeItem) {
+      return new NodeItem(
+          Math.min(nodeItem.minX, minX),
+          Math.min(nodeItem.minY, minY),
+          Math.max(nodeItem.maxX, maxX),
+          Math.max(nodeItem.maxY, maxY),
+          offset);
+    }
+
+    public boolean intersects(NodeItem nodeItem) {
+      if (nodeItem.minX > maxX) {
+        return false;
+      } else if (nodeItem.minY > maxY) {
+        return false;
+      } else if (nodeItem.maxX < minX) {
+        return false;
+      } else if (nodeItem.maxY < minY) {
+        return false;
+      } else {
+        return true;
+      }
+    }
+
+    public Envelope toEnvelope() {
+      return new Envelope(minX, maxX, minY, maxY);
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Column.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Column.java
new file mode 100644
index 00000000..7231b8bf
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Column.java
@@ -0,0 +1,233 @@
+/*
+ * 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.baremaps.flatgeobuf.generated;
+
+import com.google.flatbuffers.BaseVector;
+import com.google.flatbuffers.Constants;
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+@SuppressWarnings("all")
+public final class Column extends Table {
+  public static void ValidateVersion() {
+    Constants.FLATBUFFERS_24_3_25();
+  }
+
+  public static Column getRootAsColumn(ByteBuffer _bb) {
+    return getRootAsColumn(_bb, new Column());
+  }
+
+  public static Column getRootAsColumn(ByteBuffer _bb, Column obj) {
+    _bb.order(ByteOrder.LITTLE_ENDIAN);
+    return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb));
+  }
+
+  public void __init(int _i, ByteBuffer _bb) {
+    __reset(_i, _bb);
+  }
+
+  public Column __assign(int _i, ByteBuffer _bb) {
+    __init(_i, _bb);
+    return this;
+  }
+
+  public String name() {
+    int o = __offset(4);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer nameAsByteBuffer() {
+    return __vector_as_bytebuffer(4, 1);
+  }
+
+  public ByteBuffer nameInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 4, 1);
+  }
+
+  public int type() {
+    int o = __offset(6);
+    return o != 0 ? bb.get(o + bb_pos) & 0xFF : 0;
+  }
+
+  public String title() {
+    int o = __offset(8);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer titleAsByteBuffer() {
+    return __vector_as_bytebuffer(8, 1);
+  }
+
+  public ByteBuffer titleInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 8, 1);
+  }
+
+  public String description() {
+    int o = __offset(10);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer descriptionAsByteBuffer() {
+    return __vector_as_bytebuffer(10, 1);
+  }
+
+  public ByteBuffer descriptionInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 10, 1);
+  }
+
+  public int width() {
+    int o = __offset(12);
+    return o != 0 ? bb.getInt(o + bb_pos) : -1;
+  }
+
+  public int precision() {
+    int o = __offset(14);
+    return o != 0 ? bb.getInt(o + bb_pos) : -1;
+  }
+
+  public int scale() {
+    int o = __offset(16);
+    return o != 0 ? bb.getInt(o + bb_pos) : -1;
+  }
+
+  public boolean nullable() {
+    int o = __offset(18);
+    return o != 0 ? 0 != bb.get(o + bb_pos) : true;
+  }
+
+  public boolean unique() {
+    int o = __offset(20);
+    return o != 0 ? 0 != bb.get(o + bb_pos) : false;
+  }
+
+  public boolean primaryKey() {
+    int o = __offset(22);
+    return o != 0 ? 0 != bb.get(o + bb_pos) : false;
+  }
+
+  public String metadata() {
+    int o = __offset(24);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer metadataAsByteBuffer() {
+    return __vector_as_bytebuffer(24, 1);
+  }
+
+  public ByteBuffer metadataInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 24, 1);
+  }
+
+  public static int createColumn(FlatBufferBuilder builder,
+      int nameOffset,
+      int type,
+      int titleOffset,
+      int descriptionOffset,
+      int width,
+      int precision,
+      int scale,
+      boolean nullable,
+      boolean unique,
+      boolean primaryKey,
+      int metadataOffset) {
+    builder.startTable(11);
+    Column.addMetadata(builder, metadataOffset);
+    Column.addScale(builder, scale);
+    Column.addPrecision(builder, precision);
+    Column.addWidth(builder, width);
+    Column.addDescription(builder, descriptionOffset);
+    Column.addTitle(builder, titleOffset);
+    Column.addName(builder, nameOffset);
+    Column.addPrimaryKey(builder, primaryKey);
+    Column.addUnique(builder, unique);
+    Column.addNullable(builder, nullable);
+    Column.addType(builder, type);
+    return Column.endColumn(builder);
+  }
+
+  public static void startColumn(FlatBufferBuilder builder) {
+    builder.startTable(11);
+  }
+
+  public static void addName(FlatBufferBuilder builder, int nameOffset) {
+    builder.addOffset(0, nameOffset, 0);
+  }
+
+  public static void addType(FlatBufferBuilder builder, int type) {
+    builder.addByte(1, (byte) type, (byte) 0);
+  }
+
+  public static void addTitle(FlatBufferBuilder builder, int titleOffset) {
+    builder.addOffset(2, titleOffset, 0);
+  }
+
+  public static void addDescription(FlatBufferBuilder builder, int descriptionOffset) {
+    builder.addOffset(3, descriptionOffset, 0);
+  }
+
+  public static void addWidth(FlatBufferBuilder builder, int width) {
+    builder.addInt(4, width, -1);
+  }
+
+  public static void addPrecision(FlatBufferBuilder builder, int precision) {
+    builder.addInt(5, precision, -1);
+  }
+
+  public static void addScale(FlatBufferBuilder builder, int scale) {
+    builder.addInt(6, scale, -1);
+  }
+
+  public static void addNullable(FlatBufferBuilder builder, boolean nullable) {
+    builder.addBoolean(7, nullable, true);
+  }
+
+  public static void addUnique(FlatBufferBuilder builder, boolean unique) {
+    builder.addBoolean(8, unique, false);
+  }
+
+  public static void addPrimaryKey(FlatBufferBuilder builder, boolean primaryKey) {
+    builder.addBoolean(9, primaryKey, false);
+  }
+
+  public static void addMetadata(FlatBufferBuilder builder, int metadataOffset) {
+    builder.addOffset(10, metadataOffset, 0);
+  }
+
+  public static int endColumn(FlatBufferBuilder builder) {
+    int o = builder.endTable();
+    builder.required(o, 4); // name
+    return o;
+  }
+
+  public static final class Vector extends BaseVector {
+    public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) {
+      __reset(_vector, _element_size, _bb);
+      return this;
+    }
+
+    public Column get(int j) {
+      return get(new Column(), j);
+    }
+
+    public Column get(Column obj, int j) {
+      return obj.__assign(__indirect(__element(j), bb), bb);
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/ColumnType.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/ColumnType.java
new file mode 100644
index 00000000..a3d1472e
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/ColumnType.java
@@ -0,0 +1,46 @@
+/*
+ * 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.baremaps.flatgeobuf.generated;
+
+@SuppressWarnings("all")
+public final class ColumnType {
+  private ColumnType() {}
+
+  public static final int Byte = 0;
+  public static final int UByte = 1;
+  public static final int Bool = 2;
+  public static final int Short = 3;
+  public static final int UShort = 4;
+  public static final int Int = 5;
+  public static final int UInt = 6;
+  public static final int Long = 7;
+  public static final int ULong = 8;
+  public static final int Float = 9;
+  public static final int Double = 10;
+  public static final int String = 11;
+  public static final int Json = 12;
+  public static final int DateTime = 13;
+  public static final int Binary = 14;
+
+  public static final String[] names = {"Byte", "UByte", "Bool", "Short", "UShort", "Int", "UInt",
+      "Long", "ULong", "Float", "Double", "String", "Json", "DateTime", "Binary",};
+
+  public static String name(int e) {
+    return names[e];
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Crs.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Crs.java
new file mode 100644
index 00000000..18d07f5b
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Crs.java
@@ -0,0 +1,185 @@
+/*
+ * 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.baremaps.flatgeobuf.generated;
+
+import com.google.flatbuffers.BaseVector;
+import com.google.flatbuffers.Constants;
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+@SuppressWarnings("all")
+public final class Crs extends Table {
+  public static void ValidateVersion() {
+    Constants.FLATBUFFERS_24_3_25();
+  }
+
+  public static Crs getRootAsCrs(ByteBuffer _bb) {
+    return getRootAsCrs(_bb, new Crs());
+  }
+
+  public static Crs getRootAsCrs(ByteBuffer _bb, Crs obj) {
+    _bb.order(ByteOrder.LITTLE_ENDIAN);
+    return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb));
+  }
+
+  public void __init(int _i, ByteBuffer _bb) {
+    __reset(_i, _bb);
+  }
+
+  public Crs __assign(int _i, ByteBuffer _bb) {
+    __init(_i, _bb);
+    return this;
+  }
+
+  public String org() {
+    int o = __offset(4);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer orgAsByteBuffer() {
+    return __vector_as_bytebuffer(4, 1);
+  }
+
+  public ByteBuffer orgInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 4, 1);
+  }
+
+  public int code() {
+    int o = __offset(6);
+    return o != 0 ? bb.getInt(o + bb_pos) : 0;
+  }
+
+  public String name() {
+    int o = __offset(8);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer nameAsByteBuffer() {
+    return __vector_as_bytebuffer(8, 1);
+  }
+
+  public ByteBuffer nameInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 8, 1);
+  }
+
+  public String description() {
+    int o = __offset(10);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer descriptionAsByteBuffer() {
+    return __vector_as_bytebuffer(10, 1);
+  }
+
+  public ByteBuffer descriptionInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 10, 1);
+  }
+
+  public String wkt() {
+    int o = __offset(12);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer wktAsByteBuffer() {
+    return __vector_as_bytebuffer(12, 1);
+  }
+
+  public ByteBuffer wktInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 12, 1);
+  }
+
+  public String codeString() {
+    int o = __offset(14);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer codeStringAsByteBuffer() {
+    return __vector_as_bytebuffer(14, 1);
+  }
+
+  public ByteBuffer codeStringInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 14, 1);
+  }
+
+  public static int createCrs(FlatBufferBuilder builder,
+      int orgOffset,
+      int code,
+      int nameOffset,
+      int descriptionOffset,
+      int wktOffset,
+      int codeStringOffset) {
+    builder.startTable(6);
+    Crs.addCodeString(builder, codeStringOffset);
+    Crs.addWkt(builder, wktOffset);
+    Crs.addDescription(builder, descriptionOffset);
+    Crs.addName(builder, nameOffset);
+    Crs.addCode(builder, code);
+    Crs.addOrg(builder, orgOffset);
+    return Crs.endCrs(builder);
+  }
+
+  public static void startCrs(FlatBufferBuilder builder) {
+    builder.startTable(6);
+  }
+
+  public static void addOrg(FlatBufferBuilder builder, int orgOffset) {
+    builder.addOffset(0, orgOffset, 0);
+  }
+
+  public static void addCode(FlatBufferBuilder builder, int code) {
+    builder.addInt(1, code, 0);
+  }
+
+  public static void addName(FlatBufferBuilder builder, int nameOffset) {
+    builder.addOffset(2, nameOffset, 0);
+  }
+
+  public static void addDescription(FlatBufferBuilder builder, int descriptionOffset) {
+    builder.addOffset(3, descriptionOffset, 0);
+  }
+
+  public static void addWkt(FlatBufferBuilder builder, int wktOffset) {
+    builder.addOffset(4, wktOffset, 0);
+  }
+
+  public static void addCodeString(FlatBufferBuilder builder, int codeStringOffset) {
+    builder.addOffset(5, codeStringOffset, 0);
+  }
+
+  public static int endCrs(FlatBufferBuilder builder) {
+    int o = builder.endTable();
+    return o;
+  }
+
+  public static final class Vector extends BaseVector {
+    public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) {
+      __reset(_vector, _element_size, _bb);
+      return this;
+    }
+
+    public Crs get(int j) {
+      return get(new Crs(), j);
+    }
+
+    public Crs get(Crs obj, int j) {
+      return obj.__assign(__indirect(__element(j), bb), bb);
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Feature.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Feature.java
new file mode 100644
index 00000000..6d29ebce
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Feature.java
@@ -0,0 +1,188 @@
+/*
+ * 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.baremaps.flatgeobuf.generated;
+
+import com.google.flatbuffers.BaseVector;
+import com.google.flatbuffers.ByteVector;
+import com.google.flatbuffers.Constants;
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+@SuppressWarnings("all")
+public final class Feature extends Table {
+  public static void ValidateVersion() {
+    Constants.FLATBUFFERS_24_3_25();
+  }
+
+  public static Feature getRootAsFeature(ByteBuffer _bb) {
+    return getRootAsFeature(_bb, new Feature());
+  }
+
+  public static Feature getRootAsFeature(ByteBuffer _bb, Feature obj) {
+    _bb.order(ByteOrder.LITTLE_ENDIAN);
+    return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb));
+  }
+
+  public void __init(int _i, ByteBuffer _bb) {
+    __reset(_i, _bb);
+  }
+
+  public Feature __assign(int _i, ByteBuffer _bb) {
+    __init(_i, _bb);
+    return this;
+  }
+
+  public Geometry geometry() {
+    return geometry(new Geometry());
+  }
+
+  public Geometry geometry(Geometry obj) {
+    int o = __offset(4);
+    return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null;
+  }
+
+  public int properties(int j) {
+    int o = __offset(6);
+    return o != 0 ? bb.get(__vector(o) + j * 1) & 0xFF : 0;
+  }
+
+  public int propertiesLength() {
+    int o = __offset(6);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public ByteVector propertiesVector() {
+    return propertiesVector(new ByteVector());
+  }
+
+  public ByteVector propertiesVector(ByteVector obj) {
+    int o = __offset(6);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer propertiesAsByteBuffer() {
+    return __vector_as_bytebuffer(6, 1);
+  }
+
+  public ByteBuffer propertiesInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 6, 1);
+  }
+
+  public Column columns(int j) {
+    return columns(new Column(), j);
+  }
+
+  public Column columns(Column obj, int j) {
+    int o = __offset(8);
+    return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null;
+  }
+
+  public int columnsLength() {
+    int o = __offset(8);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public Column.Vector columnsVector() {
+    return columnsVector(new Column.Vector());
+  }
+
+  public Column.Vector columnsVector(Column.Vector obj) {
+    int o = __offset(8);
+    return o != 0 ? obj.__assign(__vector(o), 4, bb) : null;
+  }
+
+  public static int createFeature(FlatBufferBuilder builder,
+      int geometryOffset,
+      int propertiesOffset,
+      int columnsOffset) {
+    builder.startTable(3);
+    Feature.addColumns(builder, columnsOffset);
+    Feature.addProperties(builder, propertiesOffset);
+    Feature.addGeometry(builder, geometryOffset);
+    return Feature.endFeature(builder);
+  }
+
+  public static void startFeature(FlatBufferBuilder builder) {
+    builder.startTable(3);
+  }
+
+  public static void addGeometry(FlatBufferBuilder builder, int geometryOffset) {
+    builder.addOffset(0, geometryOffset, 0);
+  }
+
+  public static void addProperties(FlatBufferBuilder builder, int propertiesOffset) {
+    builder.addOffset(1, propertiesOffset, 0);
+  }
+
+  public static int createPropertiesVector(FlatBufferBuilder builder, byte[] data) {
+    return builder.createByteVector(data);
+  }
+
+  public static int createPropertiesVector(FlatBufferBuilder builder, ByteBuffer data) {
+    return builder.createByteVector(data);
+  }
+
+  public static void startPropertiesVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(1, numElems, 1);
+  }
+
+  public static void addColumns(FlatBufferBuilder builder, int columnsOffset) {
+    builder.addOffset(2, columnsOffset, 0);
+  }
+
+  public static int createColumnsVector(FlatBufferBuilder builder, int[] data) {
+    builder.startVector(4, data.length, 4);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addOffset(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startColumnsVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(4, numElems, 4);
+  }
+
+  public static int endFeature(FlatBufferBuilder builder) {
+    int o = builder.endTable();
+    return o;
+  }
+
+  public static void finishFeatureBuffer(FlatBufferBuilder builder, int offset) {
+    builder.finish(offset);
+  }
+
+  public static void finishSizePrefixedFeatureBuffer(FlatBufferBuilder builder, int offset) {
+    builder.finishSizePrefixed(offset);
+  }
+
+  public static final class Vector extends BaseVector {
+    public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) {
+      __reset(_vector, _element_size, _bb);
+      return this;
+    }
+
+    public Feature get(int j) {
+      return get(new Feature(), j);
+    }
+
+    public Feature get(Feature obj, int j) {
+      return obj.__assign(__indirect(__element(j), bb), bb);
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Geometry.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Geometry.java
new file mode 100644
index 00000000..2e03f7d5
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Geometry.java
@@ -0,0 +1,397 @@
+/*
+ * 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.baremaps.flatgeobuf.generated;
+
+import com.google.flatbuffers.BaseVector;
+import com.google.flatbuffers.Constants;
+import com.google.flatbuffers.DoubleVector;
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.IntVector;
+import com.google.flatbuffers.LongVector;
+import com.google.flatbuffers.Table;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+@SuppressWarnings("all")
+public final class Geometry extends Table {
+  public static void ValidateVersion() {
+    Constants.FLATBUFFERS_24_3_25();
+  }
+
+  public static Geometry getRootAsGeometry(ByteBuffer _bb) {
+    return getRootAsGeometry(_bb, new Geometry());
+  }
+
+  public static Geometry getRootAsGeometry(ByteBuffer _bb, Geometry obj) {
+    _bb.order(ByteOrder.LITTLE_ENDIAN);
+    return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb));
+  }
+
+  public void __init(int _i, ByteBuffer _bb) {
+    __reset(_i, _bb);
+  }
+
+  public Geometry __assign(int _i, ByteBuffer _bb) {
+    __init(_i, _bb);
+    return this;
+  }
+
+  public long ends(int j) {
+    int o = __offset(4);
+    return o != 0 ? (long) bb.getInt(__vector(o) + j * 4) & 0xFFFFFFFFL : 0;
+  }
+
+  public int endsLength() {
+    int o = __offset(4);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public IntVector endsVector() {
+    return endsVector(new IntVector());
+  }
+
+  public IntVector endsVector(IntVector obj) {
+    int o = __offset(4);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer endsAsByteBuffer() {
+    return __vector_as_bytebuffer(4, 4);
+  }
+
+  public ByteBuffer endsInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 4, 4);
+  }
+
+  public double xy(int j) {
+    int o = __offset(6);
+    return o != 0 ? bb.getDouble(__vector(o) + j * 8) : 0;
+  }
+
+  public int xyLength() {
+    int o = __offset(6);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public DoubleVector xyVector() {
+    return xyVector(new DoubleVector());
+  }
+
+  public DoubleVector xyVector(DoubleVector obj) {
+    int o = __offset(6);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer xyAsByteBuffer() {
+    return __vector_as_bytebuffer(6, 8);
+  }
+
+  public ByteBuffer xyInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 6, 8);
+  }
+
+  public double z(int j) {
+    int o = __offset(8);
+    return o != 0 ? bb.getDouble(__vector(o) + j * 8) : 0;
+  }
+
+  public int zLength() {
+    int o = __offset(8);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public DoubleVector zVector() {
+    return zVector(new DoubleVector());
+  }
+
+  public DoubleVector zVector(DoubleVector obj) {
+    int o = __offset(8);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer zAsByteBuffer() {
+    return __vector_as_bytebuffer(8, 8);
+  }
+
+  public ByteBuffer zInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 8, 8);
+  }
+
+  public double m(int j) {
+    int o = __offset(10);
+    return o != 0 ? bb.getDouble(__vector(o) + j * 8) : 0;
+  }
+
+  public int mLength() {
+    int o = __offset(10);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public DoubleVector mVector() {
+    return mVector(new DoubleVector());
+  }
+
+  public DoubleVector mVector(DoubleVector obj) {
+    int o = __offset(10);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer mAsByteBuffer() {
+    return __vector_as_bytebuffer(10, 8);
+  }
+
+  public ByteBuffer mInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 10, 8);
+  }
+
+  public double t(int j) {
+    int o = __offset(12);
+    return o != 0 ? bb.getDouble(__vector(o) + j * 8) : 0;
+  }
+
+  public int tLength() {
+    int o = __offset(12);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public DoubleVector tVector() {
+    return tVector(new DoubleVector());
+  }
+
+  public DoubleVector tVector(DoubleVector obj) {
+    int o = __offset(12);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer tAsByteBuffer() {
+    return __vector_as_bytebuffer(12, 8);
+  }
+
+  public ByteBuffer tInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 12, 8);
+  }
+
+  public long tm(int j) {
+    int o = __offset(14);
+    return o != 0 ? bb.getLong(__vector(o) + j * 8) : 0;
+  }
+
+  public int tmLength() {
+    int o = __offset(14);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public LongVector tmVector() {
+    return tmVector(new LongVector());
+  }
+
+  public LongVector tmVector(LongVector obj) {
+    int o = __offset(14);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer tmAsByteBuffer() {
+    return __vector_as_bytebuffer(14, 8);
+  }
+
+  public ByteBuffer tmInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 14, 8);
+  }
+
+  public int type() {
+    int o = __offset(16);
+    return o != 0 ? bb.get(o + bb_pos) & 0xFF : 0;
+  }
+
+  public Geometry parts(int j) {
+    return parts(new Geometry(), j);
+  }
+
+  public Geometry parts(Geometry obj, int j) {
+    int o = __offset(18);
+    return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null;
+  }
+
+  public int partsLength() {
+    int o = __offset(18);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public Geometry.Vector partsVector() {
+    return partsVector(new Geometry.Vector());
+  }
+
+  public Geometry.Vector partsVector(Geometry.Vector obj) {
+    int o = __offset(18);
+    return o != 0 ? obj.__assign(__vector(o), 4, bb) : null;
+  }
+
+  public static int createGeometry(FlatBufferBuilder builder,
+      int endsOffset,
+      int xyOffset,
+      int zOffset,
+      int mOffset,
+      int tOffset,
+      int tmOffset,
+      int type,
+      int partsOffset) {
+    builder.startTable(8);
+    Geometry.addParts(builder, partsOffset);
+    Geometry.addTm(builder, tmOffset);
+    Geometry.addT(builder, tOffset);
+    Geometry.addM(builder, mOffset);
+    Geometry.addZ(builder, zOffset);
+    Geometry.addXy(builder, xyOffset);
+    Geometry.addEnds(builder, endsOffset);
+    Geometry.addType(builder, type);
+    return Geometry.endGeometry(builder);
+  }
+
+  public static void startGeometry(FlatBufferBuilder builder) {
+    builder.startTable(8);
+  }
+
+  public static void addEnds(FlatBufferBuilder builder, int endsOffset) {
+    builder.addOffset(0, endsOffset, 0);
+  }
+
+  public static int createEndsVector(FlatBufferBuilder builder, long[] data) {
+    builder.startVector(4, data.length, 4);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addInt((int) data[i]);
+    return builder.endVector();
+  }
+
+  public static void startEndsVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(4, numElems, 4);
+  }
+
+  public static void addXy(FlatBufferBuilder builder, int xyOffset) {
+    builder.addOffset(1, xyOffset, 0);
+  }
+
+  public static int createXyVector(FlatBufferBuilder builder, double[] data) {
+    builder.startVector(8, data.length, 8);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addDouble(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startXyVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(8, numElems, 8);
+  }
+
+  public static void addZ(FlatBufferBuilder builder, int zOffset) {
+    builder.addOffset(2, zOffset, 0);
+  }
+
+  public static int createZVector(FlatBufferBuilder builder, double[] data) {
+    builder.startVector(8, data.length, 8);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addDouble(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startZVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(8, numElems, 8);
+  }
+
+  public static void addM(FlatBufferBuilder builder, int mOffset) {
+    builder.addOffset(3, mOffset, 0);
+  }
+
+  public static int createMVector(FlatBufferBuilder builder, double[] data) {
+    builder.startVector(8, data.length, 8);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addDouble(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startMVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(8, numElems, 8);
+  }
+
+  public static void addT(FlatBufferBuilder builder, int tOffset) {
+    builder.addOffset(4, tOffset, 0);
+  }
+
+  public static int createTVector(FlatBufferBuilder builder, double[] data) {
+    builder.startVector(8, data.length, 8);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addDouble(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startTVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(8, numElems, 8);
+  }
+
+  public static void addTm(FlatBufferBuilder builder, int tmOffset) {
+    builder.addOffset(5, tmOffset, 0);
+  }
+
+  public static int createTmVector(FlatBufferBuilder builder, long[] data) {
+    builder.startVector(8, data.length, 8);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addLong(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startTmVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(8, numElems, 8);
+  }
+
+  public static void addType(FlatBufferBuilder builder, int type) {
+    builder.addByte(6, (byte) type, (byte) 0);
+  }
+
+  public static void addParts(FlatBufferBuilder builder, int partsOffset) {
+    builder.addOffset(7, partsOffset, 0);
+  }
+
+  public static int createPartsVector(FlatBufferBuilder builder, int[] data) {
+    builder.startVector(4, data.length, 4);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addOffset(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startPartsVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(4, numElems, 4);
+  }
+
+  public static int endGeometry(FlatBufferBuilder builder) {
+    int o = builder.endTable();
+    return o;
+  }
+
+  public static final class Vector extends BaseVector {
+    public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) {
+      __reset(_vector, _element_size, _bb);
+      return this;
+    }
+
+    public Geometry get(int j) {
+      return get(new Geometry(), j);
+    }
+
+    public Geometry get(Geometry obj, int j) {
+      return obj.__assign(__indirect(__element(j), bb), bb);
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/GeometryType.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/GeometryType.java
new file mode 100644
index 00000000..9d13fb4a
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/GeometryType.java
@@ -0,0 +1,51 @@
+/*
+ * 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.baremaps.flatgeobuf.generated;
+
+@SuppressWarnings("all")
+public final class GeometryType {
+  private GeometryType() {}
+
+  public static final int Unknown = 0;
+  public static final int Point = 1;
+  public static final int LineString = 2;
+  public static final int Polygon = 3;
+  public static final int MultiPoint = 4;
+  public static final int MultiLineString = 5;
+  public static final int MultiPolygon = 6;
+  public static final int GeometryCollection = 7;
+  public static final int CircularString = 8;
+  public static final int CompoundCurve = 9;
+  public static final int CurvePolygon = 10;
+  public static final int MultiCurve = 11;
+  public static final int MultiSurface = 12;
+  public static final int Curve = 13;
+  public static final int Surface = 14;
+  public static final int PolyhedralSurface = 15;
+  public static final int TIN = 16;
+  public static final int Triangle = 17;
+
+  public static final String[] names =
+      {"Unknown", "Point", "LineString", "Polygon", "MultiPoint", "MultiLineString", "MultiPolygon",
+          "GeometryCollection", "CircularString", "CompoundCurve", "CurvePolygon", "MultiCurve",
+          "MultiSurface", "Curve", "Surface", "PolyhedralSurface", "TIN", "Triangle",};
+
+  public static String name(int e) {
+    return names[e];
+  }
+}
diff --git a/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Header.java b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Header.java
new file mode 100644
index 00000000..9f56de7a
--- /dev/null
+++ b/baremaps-flatgeobuf/src/main/java/org/apache/baremaps/flatgeobuf/generated/Header.java
@@ -0,0 +1,340 @@
+/*
+ * 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.baremaps.flatgeobuf.generated;
+
+import com.google.flatbuffers.BaseVector;
+import com.google.flatbuffers.Constants;
+import com.google.flatbuffers.DoubleVector;
+import com.google.flatbuffers.FlatBufferBuilder;
+import com.google.flatbuffers.Table;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+@SuppressWarnings("all")
+public final class Header extends Table {
+  public static void ValidateVersion() {
+    Constants.FLATBUFFERS_24_3_25();
+  }
+
+  public static Header getRootAsHeader(ByteBuffer _bb) {
+    return getRootAsHeader(_bb, new Header());
+  }
+
+  public static Header getRootAsHeader(ByteBuffer _bb, Header obj) {
+    _bb.order(ByteOrder.LITTLE_ENDIAN);
+    return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb));
+  }
+
+  public void __init(int _i, ByteBuffer _bb) {
+    __reset(_i, _bb);
+  }
+
+  public Header __assign(int _i, ByteBuffer _bb) {
+    __init(_i, _bb);
+    return this;
+  }
+
+  public String name() {
+    int o = __offset(4);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer nameAsByteBuffer() {
+    return __vector_as_bytebuffer(4, 1);
+  }
+
+  public ByteBuffer nameInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 4, 1);
+  }
+
+  public double envelope(int j) {
+    int o = __offset(6);
+    return o != 0 ? bb.getDouble(__vector(o) + j * 8) : 0;
+  }
+
+  public int envelopeLength() {
+    int o = __offset(6);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public DoubleVector envelopeVector() {
+    return envelopeVector(new DoubleVector());
+  }
+
+  public DoubleVector envelopeVector(DoubleVector obj) {
+    int o = __offset(6);
+    return o != 0 ? obj.__assign(__vector(o), bb) : null;
+  }
+
+  public ByteBuffer envelopeAsByteBuffer() {
+    return __vector_as_bytebuffer(6, 8);
+  }
+
+  public ByteBuffer envelopeInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 6, 8);
+  }
+
+  public int geometryType() {
+    int o = __offset(8);
+    return o != 0 ? bb.get(o + bb_pos) & 0xFF : 0;
+  }
+
+  public boolean hasZ() {
+    int o = __offset(10);
+    return o != 0 ? 0 != bb.get(o + bb_pos) : false;
+  }
+
+  public boolean hasM() {
+    int o = __offset(12);
+    return o != 0 ? 0 != bb.get(o + bb_pos) : false;
+  }
+
+  public boolean hasT() {
+    int o = __offset(14);
+    return o != 0 ? 0 != bb.get(o + bb_pos) : false;
+  }
+
+  public boolean hasTm() {
+    int o = __offset(16);
+    return o != 0 ? 0 != bb.get(o + bb_pos) : false;
+  }
+
+  public Column columns(int j) {
+    return columns(new Column(), j);
+  }
+
+  public Column columns(Column obj, int j) {
+    int o = __offset(18);
+    return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null;
+  }
+
+  public int columnsLength() {
+    int o = __offset(18);
+    return o != 0 ? __vector_len(o) : 0;
+  }
+
+  public Column.Vector columnsVector() {
+    return columnsVector(new Column.Vector());
+  }
+
+  public Column.Vector columnsVector(Column.Vector obj) {
+    int o = __offset(18);
+    return o != 0 ? obj.__assign(__vector(o), 4, bb) : null;
+  }
+
+  public long featuresCount() {
+    int o = __offset(20);
+    return o != 0 ? bb.getLong(o + bb_pos) : 0L;
+  }
+
+  public int indexNodeSize() {
+    int o = __offset(22);
+    return o != 0 ? bb.getShort(o + bb_pos) & 0xFFFF : 16;
+  }
+
+  public Crs crs() {
+    return crs(new Crs());
+  }
+
+  public Crs crs(Crs obj) {
+    int o = __offset(24);
+    return o != 0 ? obj.__assign(__indirect(o + bb_pos), bb) : null;
+  }
+
+  public String title() {
+    int o = __offset(26);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer titleAsByteBuffer() {
+    return __vector_as_bytebuffer(26, 1);
+  }
+
+  public ByteBuffer titleInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 26, 1);
+  }
+
+  public String description() {
+    int o = __offset(28);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer descriptionAsByteBuffer() {
+    return __vector_as_bytebuffer(28, 1);
+  }
+
+  public ByteBuffer descriptionInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 28, 1);
+  }
+
+  public String metadata() {
+    int o = __offset(30);
+    return o != 0 ? __string(o + bb_pos) : null;
+  }
+
+  public ByteBuffer metadataAsByteBuffer() {
+    return __vector_as_bytebuffer(30, 1);
+  }
+
+  public ByteBuffer metadataInByteBuffer(ByteBuffer _bb) {
+    return __vector_in_bytebuffer(_bb, 30, 1);
+  }
+
+  public static int createHeader(FlatBufferBuilder builder,
+      int nameOffset,
+      int envelopeOffset,
+      int geometryType,
+      boolean hasZ,
+      boolean hasM,
+      boolean hasT,
+      boolean hasTm,
+      int columnsOffset,
+      long featuresCount,
+      int indexNodeSize,
+      int crsOffset,
+      int titleOffset,
+      int descriptionOffset,
+      int metadataOffset) {
+    builder.startTable(14);
+    Header.addFeaturesCount(builder, featuresCount);
+    Header.addMetadata(builder, metadataOffset);
+    Header.addDescription(builder, descriptionOffset);
+    Header.addTitle(builder, titleOffset);
+    Header.addCrs(builder, crsOffset);
+    Header.addColumns(builder, columnsOffset);
+    Header.addEnvelope(builder, envelopeOffset);
+    Header.addName(builder, nameOffset);
+    Header.addIndexNodeSize(builder, indexNodeSize);
+    Header.addHasTm(builder, hasTm);
+    Header.addHasT(builder, hasT);
+    Header.addHasM(builder, hasM);
+    Header.addHasZ(builder, hasZ);
+    Header.addGeometryType(builder, geometryType);
+    return Header.endHeader(builder);
+  }
+
+  public static void startHeader(FlatBufferBuilder builder) {
+    builder.startTable(14);
+  }
+
+  public static void addName(FlatBufferBuilder builder, int nameOffset) {
+    builder.addOffset(0, nameOffset, 0);
+  }
+
+  public static void addEnvelope(FlatBufferBuilder builder, int envelopeOffset) {
+    builder.addOffset(1, envelopeOffset, 0);
+  }
+
+  public static int createEnvelopeVector(FlatBufferBuilder builder, double[] data) {
+    builder.startVector(8, data.length, 8);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addDouble(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startEnvelopeVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(8, numElems, 8);
+  }
+
+  public static void addGeometryType(FlatBufferBuilder builder, int geometryType) {
+    builder.addByte(2, (byte) geometryType, (byte) 0);
+  }
+
+  public static void addHasZ(FlatBufferBuilder builder, boolean hasZ) {
+    builder.addBoolean(3, hasZ, false);
+  }
+
+  public static void addHasM(FlatBufferBuilder builder, boolean hasM) {
+    builder.addBoolean(4, hasM, false);
+  }
+
+  public static void addHasT(FlatBufferBuilder builder, boolean hasT) {
+    builder.addBoolean(5, hasT, false);
+  }
+
+  public static void addHasTm(FlatBufferBuilder builder, boolean hasTm) {
+    builder.addBoolean(6, hasTm, false);
+  }
+
+  public static void addColumns(FlatBufferBuilder builder, int columnsOffset) {
+    builder.addOffset(7, columnsOffset, 0);
+  }
+
+  public static int createColumnsVector(FlatBufferBuilder builder, int[] data) {
+    builder.startVector(4, data.length, 4);
+    for (int i = data.length - 1; i >= 0; i--)
+      builder.addOffset(data[i]);
+    return builder.endVector();
+  }
+
+  public static void startColumnsVector(FlatBufferBuilder builder, int numElems) {
+    builder.startVector(4, numElems, 4);
+  }
+
+  public static void addFeaturesCount(FlatBufferBuilder builder, long featuresCount) {
+    builder.addLong(8, featuresCount, 0L);
+  }
+
+  public static void addIndexNodeSize(FlatBufferBuilder builder, int indexNodeSize) {
+    builder.addShort(9, (short) indexNodeSize, (short) 16);
+  }
+
+  public static void addCrs(FlatBufferBuilder builder, int crsOffset) {
+    builder.addOffset(10, crsOffset, 0);
+  }
+
+  public static void addTitle(FlatBufferBuilder builder, int titleOffset) {
+    builder.addOffset(11, titleOffset, 0);
+  }
+
+  public static void addDescription(FlatBufferBuilder builder, int descriptionOffset) {
+    builder.addOffset(12, descriptionOffset, 0);
+  }
+
+  public static void addMetadata(FlatBufferBuilder builder, int metadataOffset) {
+    builder.addOffset(13, metadataOffset, 0);
+  }
+
+  public static int endHeader(FlatBufferBuilder builder) {
+    int o = builder.endTable();
+    return o;
+  }
+
+  public static void finishHeaderBuffer(FlatBufferBuilder builder, int offset) {
+    builder.finish(offset);
+  }
+
+  public static void finishSizePrefixedHeaderBuffer(FlatBufferBuilder builder, int offset) {
+    builder.finishSizePrefixed(offset);
+  }
+
+  public static final class Vector extends BaseVector {
+    public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) {
+      __reset(_vector, _element_size, _bb);
+      return this;
+    }
+
+    public Header get(int j) {
+      return get(new Header(), j);
+    }
+
+    public Header get(Header obj, int j) {
+      return obj.__assign(__indirect(__element(j), bb), bb);
+    }
+  }
+}
diff --git a/baremaps-flatgeobuf/src/test/java/org/apache/baremaps/flatgeobuf/FlatGeoBufTest.java b/baremaps-flatgeobuf/src/test/java/org/apache/baremaps/flatgeobuf/FlatGeoBufTest.java
new file mode 100644
index 00000000..cce3d978
--- /dev/null
+++ b/baremaps-flatgeobuf/src/test/java/org/apache/baremaps/flatgeobuf/FlatGeoBufTest.java
@@ -0,0 +1,207 @@
+/*
+ * 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.baremaps.flatgeobuf;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.channels.WritableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.baremaps.flatgeobuf.generated.Feature;
+import org.apache.baremaps.flatgeobuf.generated.Header;
+import org.apache.baremaps.testing.TestFiles;
+import org.junit.jupiter.api.Test;
+
+class FlatGeoBufTest {
+
+  @Test
+  void readWrite() throws IOException {
+    Path file = TestFiles.resolve("baremaps-testing/data/samples/countries.fgb");
+    Path tempFile = Files.createTempFile("countries", ".fgb");
+
+    Header headerFlatGeoBuf1 = null;
+    FlatGeoBuf.Header headerRecord1 = null;
+    List<FlatGeoBuf.Feature> featureRecords = new ArrayList<>();
+
+    // Copy the file
+    try (
+        FlatGeoBufReader reader =
+            new FlatGeoBufReader(FileChannel.open(file, StandardOpenOption.READ));
+        FlatGeoBufWriter writer =
+            new FlatGeoBufWriter(FileChannel.open(tempFile, StandardOpenOption.WRITE))) {
+
+      // Copy the header
+      headerRecord1 = reader.readHeader();
+      headerFlatGeoBuf1 = FlatGeoBufWriter.asFlatBuffer(headerRecord1);
+      writer.writeHeader(headerRecord1);
+
+      // Copy the index
+      ByteBuffer indexBuffer = reader.readIndexBuffer();
+      writer.writeIndexBuffer(indexBuffer);
+
+      // Copy the features
+      for (long i = 0; i < headerFlatGeoBuf1.featuresCount(); i++) {
+        FlatGeoBuf.Feature feature = reader.readFeature();
+        writer.writeFeature(feature);
+        featureRecords.add(feature);
+      }
+    }
+
+    // Read the copied file
+    try (FlatGeoBufReader reader =
+        new FlatGeoBufReader(FileChannel.open(tempFile, StandardOpenOption.READ))) {
+
+      // Read the header
+      FlatGeoBuf.Header headerRecord2 = reader.readHeader();
+      Header headerFlatGeoBuf2 = FlatGeoBufWriter.asFlatBuffer(headerRecord2);
+      assertNotNull(headerFlatGeoBuf2);
+      assertEquals(headerRecord1, headerRecord2);
+
+      // Read the index
+      reader.skipIndex();
+
+      // Read the features
+      for (long i = 0; i < headerFlatGeoBuf2.featuresCount(); i++) {
+        FlatGeoBuf.Feature featureRecord = reader.readFeature();
+        assertNotNull(featureRecord);
+        assertEquals(featureRecords.get((int) i), featureRecord);
+      }
+    }
+  }
+
+  @Test
+  void readWriteBuffer() throws IOException {
+    Path file = TestFiles.resolve("baremaps-testing/data/samples/countries.fgb");
+    Path tempFile = Files.createTempFile("countries", ".fgb");
+
+    Header headerFlatGeoBuf1 = null;
+    FlatGeoBuf.Header headerRecord1 = null;
+    List<FlatGeoBuf.Feature> featureRecords = new ArrayList<>();
+
+    // Copy the file
+    try (ReadableByteChannel channel = FileChannel.open(file, StandardOpenOption.READ);
+        WritableByteChannel tempChannel = FileChannel.open(tempFile, StandardOpenOption.WRITE)) {
+
+      // Copy the header
+      headerFlatGeoBuf1 = FlatGeoBufReader.readHeaderBuffer(channel);
+      headerRecord1 = FlatGeoBufReader.asFlatGeoBuf(headerFlatGeoBuf1);
+      FlatGeoBufWriter.writeHeaderBuffer(tempChannel, headerFlatGeoBuf1);
+
+      // Copy the index
+      ByteBuffer indexBuffer = FlatGeoBufReader.readIndexBuffer(channel, headerFlatGeoBuf1);
+      FlatGeoBufWriter.writeIndexBuffer(tempChannel, indexBuffer);
+
+      // Copy the features
+      var buffer = ByteBuffer.allocate(1 << 10).order(ByteOrder.LITTLE_ENDIAN);
+      for (long i = 0; i < headerFlatGeoBuf1.featuresCount(); i++) {
+        Feature feature = FlatGeoBufReader.readFeatureBuffer(channel, buffer);
+        FlatGeoBufWriter.writeFeatureBuffer(tempChannel, feature);
+        FlatGeoBuf.Feature featureRecord =
+            FlatGeoBufReader.asFlatGeoBuf(headerFlatGeoBuf1, feature);
+        featureRecords.add(featureRecord);
+      }
+    }
+
+    // Read the copied file
+    try (var channel = FileChannel.open(tempFile, StandardOpenOption.READ)) {
+
+      // Read the header
+      Header headerFlatGeoBuf2 = FlatGeoBufReader.readHeaderBuffer(channel);
+      FlatGeoBuf.Header headerRecord2 = FlatGeoBufReader.asFlatGeoBuf(headerFlatGeoBuf2);
+      assertNotNull(headerFlatGeoBuf2);
+      assertEquals(headerRecord1, headerRecord2);
+
+      // Read the index
+      FlatGeoBufReader.skipIndex(channel, headerFlatGeoBuf2);
+
+      // Read the features
+      ByteBuffer buffer = ByteBuffer.allocate(1 << 10).order(ByteOrder.LITTLE_ENDIAN);
+      for (long i = 0; i < headerFlatGeoBuf2.featuresCount(); i++) {
+        Feature featureFlatGeoBuf = FlatGeoBufReader.readFeatureBuffer(channel, buffer);
+        FlatGeoBuf.Feature featureRecord =
+            FlatGeoBufReader.asFlatGeoBuf(headerFlatGeoBuf2, featureFlatGeoBuf);
+        assertNotNull(featureRecord);
+        assertEquals(featureRecords.get((int) i), featureRecord);
+      }
+    }
+  }
+
+  @Test
+  void readWriteRecord() throws IOException {
+    Path file = TestFiles.resolve("baremaps-testing/data/samples/countries.fgb");
+    Path tempFile = Files.createTempFile("countries", ".fgb");
+
+    Header headerFlatGeoBuf1 = null;
+    FlatGeoBuf.Header headerRecord1 = null;
+    List<FlatGeoBuf.Feature> featureRecords = new ArrayList<>();
+
+    // Copy the file
+    try (ReadableByteChannel channel = FileChannel.open(file, StandardOpenOption.READ);
+        WritableByteChannel tempChannel = FileChannel.open(tempFile, StandardOpenOption.WRITE)) {
+
+      // Copy the header
+      headerRecord1 = FlatGeoBufReader.readHeader(channel);
+      headerFlatGeoBuf1 = FlatGeoBufWriter.asFlatBuffer(headerRecord1);
+      FlatGeoBufWriter.writeHeader(tempChannel, headerRecord1);
+
+      // Copy the index
+      ByteBuffer indexBuffer = FlatGeoBufReader.readIndexBuffer(channel, headerFlatGeoBuf1);
+      FlatGeoBufWriter.writeIndexBuffer(tempChannel, indexBuffer);
+
+      // Copy the features
+      var buffer = ByteBuffer.allocate(1 << 10).order(ByteOrder.LITTLE_ENDIAN);
+      for (long i = 0; i < headerFlatGeoBuf1.featuresCount(); i++) {
+        FlatGeoBuf.Feature feature =
+            FlatGeoBufReader.readFeature(channel, headerFlatGeoBuf1, buffer);
+        FlatGeoBufWriter.writeFeature(tempChannel, headerFlatGeoBuf1, feature);
+        featureRecords.add(feature);
+      }
+    }
+
+    // Read the copied file
+    try (var channel = FileChannel.open(tempFile, StandardOpenOption.READ)) {
+
+      // Read the header
+      FlatGeoBuf.Header headerRecord2 = FlatGeoBufReader.readHeader(channel);
+      Header headerFlatGeoBuf2 = FlatGeoBufWriter.asFlatBuffer(headerRecord2);
+      assertNotNull(headerFlatGeoBuf2);
+      assertEquals(headerRecord1, headerRecord2);
+
+      // Read the index
+      FlatGeoBufReader.skipIndex(channel, headerFlatGeoBuf2);
+
+      // Read the features
+      ByteBuffer buffer = ByteBuffer.allocate(1 << 10).order(ByteOrder.LITTLE_ENDIAN);
+      for (long i = 0; i < headerFlatGeoBuf2.featuresCount(); i++) {
+        FlatGeoBuf.Feature featureRecord =
+            FlatGeoBufReader.readFeature(channel, headerFlatGeoBuf2, buffer);
+        assertNotNull(featureRecord);
+        assertEquals(featureRecords.get((int) i), featureRecord);
+      }
+    }
+  }
+
+}
diff --git a/baremaps-geoparquet/src/test/java/org/apache/baremaps/geoparquet/GeoParquetReaderTest.java b/baremaps-geoparquet/src/test/java/org/apache/baremaps/geoparquet/GeoParquetReaderTest.java
index 618bf619..6d942a55 100644
--- a/baremaps-geoparquet/src/test/java/org/apache/baremaps/geoparquet/GeoParquetReaderTest.java
+++ b/baremaps-geoparquet/src/test/java/org/apache/baremaps/geoparquet/GeoParquetReaderTest.java
@@ -39,17 +39,6 @@ class GeoParquetReaderTest {
     readGroups(geoParquet, isParallel, expectedGroupCount);
   }
 
-  @Disabled("Takes too long. Around 4 minutes.")
-  @Test
-  void readExternal() throws URISyntaxException {
-    URI geoParquet = new URI(
-        "s3a://overturemaps-us-west-2/release/2024-03-12-alpha.0/theme=admins/type=locality_area/*.parquet");
-    final boolean isParallel = true;
-    final int expectedGroupCount = 974708;
-
-    readGroups(geoParquet, isParallel, expectedGroupCount);
-  }
-
   private static void readGroups(URI geoParquet, boolean parallel,
       int expectedGroupCount) {
     GeoParquetReader geoParquetReader = new GeoParquetReader(geoParquet);
@@ -65,6 +54,18 @@ class GeoParquetReaderTest {
     assertEquals(expectedGroupCount, groupCount.get());
   }
 
+  @Disabled("Requires access to the Internet")
+  @Test
+  void readExternal() throws URISyntaxException {
+    URI geoParquet = new URI(
+        "s3a://overturemaps-us-west-2/release/2024-03-12-alpha.0/theme=admins/type=locality_area/*.parquet");
+    final boolean isParallel = true;
+    final int expectedGroupCount = 974708;
+
+    readGroups(geoParquet, isParallel, expectedGroupCount);
+  }
+
+  @Disabled("Requires access to the Internet")
   @Test
   void validateSchemas() throws URISyntaxException {
     URI geoParquet = new URI(
@@ -74,6 +75,7 @@ class GeoParquetReaderTest {
     assertTrue(geoParquetReader.validateSchemasAreIdentical(), "Schemas are identical");
   }
 
+  @Disabled("Requires access to the Internet")
   @Test
   void sizeForLocalities() throws URISyntaxException {
     URI geoParquet = new URI(
@@ -82,6 +84,7 @@ class GeoParquetReaderTest {
     assertEquals(974708L, geoParquetReader.size());
   }
 
+  @Disabled("Requires access to the Internet")
   @Test
   void sizeForBuildings() throws URISyntaxException {
     URI geoParquet = new URI(
diff --git a/baremaps-testing/pom.xml b/baremaps-testing/pom.xml
index c2578470..ac8075f4 100644
--- a/baremaps-testing/pom.xml
+++ b/baremaps-testing/pom.xml
@@ -10,4 +10,10 @@
   <properties>
     <maven.deploy.skip>true</maven.deploy.skip>
   </properties>
+  <dependencies>
+    <dependency>
+      <groupId>org.locationtech.jts</groupId>
+      <artifactId>jts-core</artifactId>
+    </dependency>
+  </dependencies>
 </project>
diff --git a/pom.xml b/pom.xml
index eb1e3bd5..67dbdf64 100644
--- a/pom.xml
+++ b/pom.xml
@@ -52,6 +52,7 @@ limitations under the License.
     <module>baremaps-cli</module>
     <module>baremaps-core</module>
     <module>baremaps-data</module>
+    <module>baremaps-flatgeobuf</module>
     <module>baremaps-geoparquet</module>
     <module>baremaps-maplibre</module>
     <module>baremaps-openstreetmap</module>
@@ -237,6 +238,11 @@ limitations under the License.
         <artifactId>baremaps-data</artifactId>
         <version>${project.parent.version}</version>
       </dependency>
+      <dependency>
+        <groupId>org.apache.baremaps</groupId>
+        <artifactId>baremaps-flatgeobuf</artifactId>
+        <version>${project.parent.version}</version>
+      </dependency>
       <dependency>
         <groupId>org.apache.baremaps</groupId>
         <artifactId>baremaps-geoparquet</artifactId>
@@ -443,11 +449,6 @@ limitations under the License.
         <version>${version.lib.testcontainers}</version>
         <scope>test</scope>
       </dependency>
-      <dependency>
-        <groupId>org.wololo</groupId>
-        <artifactId>flatgeobuf</artifactId>
-        <version>${version.lib.flatgeobuf}</version>
-      </dependency>
       <dependency>
         <groupId>org.xerial</groupId>
         <artifactId>sqlite-jdbc</artifactId>
@@ -474,7 +475,10 @@ limitations under the License.
       <groupId>com.fasterxml.jackson.core</groupId>
       <artifactId>jackson-databind</artifactId>
     </dependency>
-    <!-- Provide logging for any child module during test -->
+    <dependency>
+      <groupId>com.google.guava</groupId>
+      <artifactId>guava</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.logging.log4j</groupId>
       <artifactId>log4j-core</artifactId>
@@ -526,10 +530,6 @@ limitations under the License.
       <groupId>org.testcontainers</groupId>
       <artifactId>testcontainers</artifactId>
     </dependency>
-    <dependency>
-      <groupId>org.wololo</groupId>
-      <artifactId>flatgeobuf</artifactId>
-    </dependency>
   </dependencies>
 
   <build>
diff --git a/scripts/generate-flatgeobuf.sh b/scripts/generate-flatgeobuf.sh
new file mode 100755
index 00000000..ea5317da
--- /dev/null
+++ b/scripts/generate-flatgeobuf.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+flatc --java --gen-all \
+  --java-package-prefix org.apache.baremaps.flatgeobuf.generated \
+  -o baremaps-flatgeobuf/src/main/java \
+  baremaps-flatgeobuf/src/main/resources/fbs/*.fbs