# @crmackey/shapefile-wasm > Read and write ESRI Shapefiles from GeoJSON, in the browser, in Node and in > workers. A Rust core compiled to WebAssembly with a typed TypeScript API. > No server round-trip, no GDAL, no native build step. Install with `npm install @crmackey/shapefile-wasm`. Requires Node 18+ or any browser with WebAssembly. The `.wasm` binary ships embedded, so there is nothing to copy, serve, or configure in a bundler. ## Constraints that determine whether generated code is correct These are properties of the shapefile format, not of this library. Code that ignores them compiles and then produces wrong files. - **One geometry type per file.** A shapefile cannot hold points and polygons together. Mixed input throws, naming the offending feature index. The single exception: `Point` and `MultiPoint` are promoted to one `Multipoint` file. For mixed data use `writeLayersZip`, which writes one shapefile per layer. - **`GeometryCollection` is not representable** and is rejected. - **Field names are capped at 11 bytes** by dBase. Longer names are truncated and de-duplicated. Always read `parts.fields` to see the mapping — each entry has `source` (original) and `name` (written). - **Character fields are capped at 254 bytes**; longer strings are truncated on a character boundary. - **Coordinates are never reprojected.** `epsg` / `wkt` only write the `.prj` that records which system the coordinates are already in. Use proj4js to actually transform them. - **An unresolvable EPSG code throws** rather than silently omitting the `.prj`. - **Features with `null` geometry are skipped**, and counted in `skippedCount`. - **M (measure) values are dropped on read** unless `includeM: true` is passed, because GeoJSON has no concept of measures. - **Text is always trimmed on read.** dBase pads character columns to a fixed width and the underlying reader strips it; there is no option to keep it. ## API No `init()` call is needed — the WebAssembly module is instantiated on first use. Every function is async except `zipParts` and `zipLayers`. From `@crmackey/shapefile-wasm`: - `writeShapefile(geojson, options?)` — GeoJSON to `{ shp, shx, dbf, cpg, prj?, shapeType, dimensions, featureCount, skippedCount, bbox, fields }` - `writeShapefileZip(geojson, options?)` — GeoJSON to a single zip as `Uint8Array` - `zipParts(parts, options?)` — pack already-generated components (synchronous) - `writeLayersZip(layers, options?)` — several layers to one archive; `layers` is `{ name, geojson, ...options }[]` - `writeLayers(layers, options?)` — convert a batch without packing, for per-layer metadata - `zipLayers(written, options?)` — pack the result of `writeLayers` (synchronous) - `readShapefile({ shp, dbf?, cpg?, prj? }, options?)` — components to a GeoJSON `FeatureCollection` - `readShapefileZip(archive, options?)` — a zip to `ShapefileLayer[]`, one per layer - `fetchProjection(epsg, options?)` — look up WKT from epsg.io over the network - `registerProjections(map)`, `getProjection(epsg)`, `registeredProjections()`, `loadProjectionTable()` - `init(source?)`, `isReady()` — only needed to control when the module loads From `@crmackey/shapefile-wasm/browser` (needs a DOM): - `downloadShapefileZip(geojson, options?)` — convert and save to the user's downloads - `readShapefileFile(file, options?)` — read a zip from a `File` or `Blob` - `triggerDownload(data, fileName, mimeType?)` — save arbitrary bytes Other entry points: `@crmackey/shapefile-wasm/slim` (same API, no embedded binary, requires `await init(url)` first) and `@crmackey/shapefile-wasm/projections` (the raw EPSG table). ## Write options `shapeType` (`point` | `multipoint` | `polyline` | `polygon`, default inferred), `dimensions` (`auto` | `xy` | `xym` | `xyz` | `xyzm`, default `auto`), `maxFieldLength` (1-254, default 254), `epsg` (number), `wkt` (string, beats `epsg`), `fileName` (zip only), `level` (0-9 DEFLATE, zip only), and for `writeLayersZip` also `layout` (`flat` | `folders` | `nested`, default `flat`) and `folder` (a top-level directory inside the archive). ## Read options `encoding` (defaults to the `.cpg`, then UTF-8; legacy code pages such as `cp1252`, `cp437`, `cp850` are supported) and `includeM` (default `false`). ## Canonical example ```ts import { writeLayersZip, readShapefileZip } from '@crmackey/shapefile-wasm'; import { triggerDownload } from '@crmackey/shapefile-wasm/browser'; // Mixed geometry needs one shapefile per type, which writeLayersZip handles. const zip = await writeLayersZip( [ { name: 'Manholes', geojson: points }, { name: 'Pipes', geojson: lines }, { name: 'Basins', geojson: polygons }, ], { epsg: 26915, layout: 'flat' }, ); triggerDownload(zip, 'network.zip'); // And back again. One archive can hold several layers. const layers = await readShapefileZip(zip); for (const layer of layers) { console.log(layer.name, layer.geojson.features.length, layer.prj); } ``` ## Docs - [Guide](https://calebm1987.github.io/shapefile-wasm/guide/): overview and rationale - [Getting started](https://calebm1987.github.io/shapefile-wasm/guide/getting-started): install and first conversion - [Writing shapefiles](https://calebm1987.github.io/shapefile-wasm/guide/writing): input shapes, schema inference, errors - [Reading shapefiles](https://calebm1987.github.io/shapefile-wasm/guide/reading): archives, encodings, ring nesting - [Multiple layers](https://calebm1987.github.io/shapefile-wasm/guide/multiple-layers): batch export and archive layouts - [Projections](https://calebm1987.github.io/shapefile-wasm/guide/projections): EPSG codes, custom WKT, epsg.io lookup - [In the browser](https://calebm1987.github.io/shapefile-wasm/guide/browser): downloads, file pickers, workers, CSP - [Entry points](https://calebm1987.github.io/shapefile-wasm/guide/entry-points): root vs slim vs browser - [Format mapping](https://calebm1987.github.io/shapefile-wasm/guide/format-mapping): every GeoJSON to shapefile correspondence - [Known limits](https://calebm1987.github.io/shapefile-wasm/guide/limits): what the format and this package cannot do - [Troubleshooting](https://calebm1987.github.io/shapefile-wasm/guide/troubleshooting): error messages and their causes - [TypeScript API reference](https://calebm1987.github.io/shapefile-wasm/reference/): generated signatures - [Live demo](https://calebm1987.github.io/shapefile-wasm/demo): running application ## Optional - [Architecture](https://calebm1987.github.io/shapefile-wasm/guide/architecture): why the code is shaped this way - [Development](https://calebm1987.github.io/shapefile-wasm/guide/development): building from source - [Testing](https://calebm1987.github.io/shapefile-wasm/guide/testing): the test strategy - [Rust API](https://calebm1987.github.io/shapefile-wasm/rust): the core crate, usable without the wasm bindings - [Full documentation as one file](https://calebm1987.github.io/shapefile-wasm/llms-full.txt) --- # Full documentation The complete guide, concatenated. Generated by scripts/build-llms.mjs. --- # What it is `@crmackey/shapefile-wasm` converts between GeoJSON and ESRI Shapefiles, entirely on the client. The conversion runs in a Rust core compiled to WebAssembly; a thin TypeScript layer handles zipping, projection files and the browser-specific bits. No server round-trip, no GDAL, and no native build step for anyone installing it. ## The problem it solves A shapefile is a set of files that have to agree with one another: | File | Holds | Required | | --- | --- | --- | | `.shp` | The geometry | Yes | | `.shx` | A fixed-width index into the `.shp` | Expected by most software | | `.dbf` | Attributes, in the dBase III format | Yes | | `.prj` | The coordinate system, as ESRI WKT | Strongly recommended | | `.cpg` | The `.dbf`'s character encoding | Recommended | Writing them by hand in JavaScript is fiddly. Writing them *slightly* wrong produces files that open in QGIS and break in ArcGIS, or that load with the right shapes and quietly corrupted attributes. ## What it handles for you **dBase column widths are measured, not guessed.** The `dbase` crate silently crops any value that overruns its declared column. A guessed width turns `-123456789.125` into `-1234567` — still a valid number, just the wrong one. This package makes a pass over your data first and sizes each column to the widest value actually present. **Polygon holes are re-nested on read.** A shapefile stores every ring of every polygon in one flat list, marked only as outer or inner. Turning that back into GeoJSON means working out which exterior each hole belongs to. Pairing them in order is the easy approach and it is wrong; this reader does a point-in-polygon test and picks the smallest containing ring, so two adjacent donuts keep their own holes. **Ring winding is corrected in both directions.** Shapefiles wind exterior rings clockwise; RFC 7946 wants counter-clockwise with holes reversed. **Field names are truncated and de-duplicated.** dBase caps names at 11 bytes, so `measurement_one` and `measurement_two` both become `measurement`. The second is given a suffix, and the full original-to-written mapping comes back in [`fields`](/reference/index/interfaces/ShapefileParts) so you can report renames rather than discover them later. **Ragged properties are padded.** `dbase` treats a record that omits a declared field as a hard error, not a null, so every row is filled out before it is written. **Features with no geometry are skipped, and counted.** Writing an attribute row with no shape would desynchronise the `.shp` and `.dbf` record numbering, which is exactly the kind of corruption that surfaces months later. ## What it does not do It is not a projection engine — it writes the `.prj` you ask for, but it will not reproject your coordinates. Pair it with [proj4js](https://github.com/proj4js/proj4js) if you need that. See [Known limits](/guide/limits) for the full list. ## Next - [Getting started](/guide/getting-started) — install and first conversion - [Writing shapefiles](/guide/writing) - [Reading shapefiles](/guide/reading) - [API reference](/reference/) --- # Getting started ## Install ::: code-group ```bash [npm] npm install @crmackey/shapefile-wasm ``` ```bash [pnpm] pnpm add @crmackey/shapefile-wasm ``` ```bash [yarn] yarn add @crmackey/shapefile-wasm ``` Requires Node 18+ or any browser with WebAssembly. The `.wasm` binary ships embedded in the package, so there is nothing to copy, serve, or configure in your bundler. If you would rather stream it as a separate asset, see [Entry points](/guide/entry-points). ## Your first conversion ```ts import { writeShapefileZip } from '@crmackey/shapefile-wasm'; const featureCollection = { type: 'FeatureCollection', features: [ { type: 'Feature', geometry: { type: 'Point', coordinates: [-93.265, 44.9778] }, properties: { name: 'Minneapolis', pop: 429954, county_seat: true }, }, { type: 'Feature', geometry: { type: 'Point', coordinates: [-93.09, 44.9537] }, properties: { name: 'Saint Paul', pop: 311527, county_seat: true }, }, ], }; const zip = await writeShapefileZip(featureCollection, { fileName: 'cities', epsg: 4326, }); ``` `zip` is a `Uint8Array` holding `cities.shp`, `cities.shx`, `cities.dbf`, `cities.cpg` and `cities.prj`. Write it to disk, upload it, or hand it to the browser. Every function instantiates the WebAssembly module on first use. Call [`init()`](/reference/index/functions/init) only if you want to control *when* that roughly 50 ms cost is paid. ## And back again ```ts import { readShapefileZip } from '@crmackey/shapefile-wasm'; const layers = await readShapefileZip(zip); layers[0].name; // "cities" layers[0].geojson.features.length; // 2 layers[0].geojson.features[0].properties; // { name: "Minneapolis", ... } layers[0].prj; // the WKT that was written ``` An archive can hold several layers, including in nested folders, so the result is always a list. ## Working with the raw parts When you need the individual files rather than an archive: ```ts import { writeShapefile } from '@crmackey/shapefile-wasm'; const parts = await writeShapefile(featureCollection, { epsg: 4326 }); await writeFile('cities.shp', parts.shp); await writeFile('cities.shx', parts.shx); await writeFile('cities.dbf', parts.dbf); await writeFile('cities.cpg', parts.cpg); await writeFile('cities.prj', parts.prj!); ``` The result also reports what was inferred: ```ts parts.shapeType; // "Point" parts.dimensions; // "xy" parts.featureCount; // 2 parts.skippedCount; // 0 parts.bbox; // [minX, minY, maxX, maxY] parts.fields; // how each property became a .dbf column ``` ## Check for renamed fields dBase caps column names at 11 bytes. It is worth surfacing what changed: ```ts for (const field of parts.fields) { if (field.source !== field.name) { console.warn(`"${field.source}" was written as "${field.name}"`); } } ``` ## Next - [Writing shapefiles](/guide/writing) — options, schema inference, errors - [Reading shapefiles](/guide/reading) — encodings, archives, geometry - [Projections](/guide/projections) — EPSG codes and custom WKT --- # Entry points The package ships several subpaths so you only pull in what you use. | Import | Contents | | --- | --- | | `@crmackey/shapefile-wasm` | Core API, WebAssembly binary embedded | | `@crmackey/shapefile-wasm/slim` | Same API, no embedded binary | | `@crmackey/shapefile-wasm/browser` | DOM helpers: download, file input | | `@crmackey/shapefile-wasm/projections` | The raw EPSG table | | `@crmackey/shapefile-wasm/wasm` | The `.wasm` file itself | ## Root: zero configuration ```ts import { writeShapefileZip } from '@crmackey/shapefile-wasm'; ``` The binary is embedded as base64. Nothing to copy, nothing to serve, no bundler plugin — it works the same in Vite, webpack, Next.js, plain Node and a worker. The cost is roughly a third more bytes than the raw `.wasm`, plus a base64 decode before compilation. The blob is behind a dynamic import, so it is only fetched when you actually convert something. ## Slim: stream the binary When you can serve the `.wasm` as its own asset, this is the better trade — it is smaller and the browser compiles it while it downloads. ```ts import { init, writeShapefileZip } from '@crmackey/shapefile-wasm/slim'; import wasmUrl from '@crmackey/shapefile-wasm/wasm?url'; // Vite await init(wasmUrl); const zip = await writeShapefileZip(data); ``` `/slim` has no binary to fall back on. Calling anything before `init()` throws with an explanation. In Node, hand it bytes: ```ts import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { init } from '@crmackey/shapefile-wasm/slim'; const require = createRequire(import.meta.url); await init(await readFile(require.resolve('@crmackey/shapefile-wasm/wasm'))); ``` `init` accepts a URL string or `URL`, a `Response`, an `ArrayBuffer` or `Uint8Array`, or an already-compiled `WebAssembly.Module`. ## Browser: DOM helpers Kept separate so the core never touches `document` — which is what makes it safe in Node, in workers, and during server-side rendering. ```ts import { downloadShapefileZip } from '@crmackey/shapefile-wasm/browser'; ``` See [In the browser](/guide/browser). ## Projections: the raw table Normally unnecessary — the table loads on demand. Import it directly to bundle it eagerly or to inspect it: ```ts import { epsgProjections } from '@crmackey/shapefile-wasm/projections'; Object.keys(epsgProjections).length; // 116 ``` ## Controlling when the module loads Instantiation costs roughly 50 ms. Pay it somewhere the user is already waiting: ```ts import { init, isReady } from '@crmackey/shapefile-wasm'; // During app startup, behind a splash screen. void init(); // Later, if you need to know. if (!isReady()) showSpinner(); ``` `init()` is idempotent — later calls await the same instantiation. A failure is not cached, so a transient network error can be retried. --- # Writing shapefiles Two functions cover almost everything: [`writeShapefile`](/reference/index/functions/writeShapefile) returns the raw components, and [`writeShapefileZip`](/reference/index/functions/writeShapefileZip) wraps them into one archive. ## Accepted input Both take any of these: ```ts await writeShapefile(featureCollection); // a FeatureCollection await writeShapefile(feature); // one Feature await writeShapefile(feature.geometry); // a bare geometry await writeShapefile([featureA, featureB]); // an array of either await writeShapefile(JSON.stringify(collection)); // a GeoJSON string ``` A string is parsed in Rust, which skips a `JSON.parse` in JavaScript. Worth it when the data arrived as text over the network anyway. ## One file, one geometry type A shapefile holds exactly one geometry type. Mixed input is rejected with an error naming the offending feature index: ``` a shapefile holds a single geometry type, but the input mixes Polyline and Polygon (feature 3); split the input or pass an explicit `shapeType` ``` The one exception is `Point` and `MultiPoint`, which are promoted to a single `Multipoint` file — they are compatible, so there is no reason to fail. To force a family regardless of what the data looks like: ```ts await writeShapefile(points, { shapeType: 'multipoint' }); ``` See [Format mapping](/guide/format-mapping) for the full table. ## Dimensions By default the ordinates decide: | Coordinates look like | Written as | | --- | --- | | `[x, y]` | `Point`, `Polyline`, `Polygon`, `Multipoint` | | `[x, y, z]` | the `Z` variants | | `[x, y, z, m]` | the `Z` variants, with measures | The richest coordinate in the whole set wins, so one 3D vertex promotes the file. Override when the third ordinate is really a measure rather than an elevation: ```ts await writeShapefile(data, { dimensions: 'xym' }); // 3rd ordinate is M await writeShapefile(data, { dimensions: 'xy' }); // drop Z entirely ``` ## How attributes become .dbf columns Every feature's properties are scanned, and each property becomes one column. | Values seen | Column type | | --- | --- | | Only numbers | `numeric` | | Only booleans | `logical` | | Only strings | `character` | | Objects or arrays | `character`, JSON-encoded | | More than one of the above | `character` | | Only `null` | `character`, width 1 | Column order follows first appearance, not alphabetical order. ### Widths are measured, not guessed `dbase` silently crops anything that overruns its column, so widths come from the data. A `numeric` column takes the most decimal places any value needed, then the width required to print the widest value at that precision. ```ts const { fields } = await writeShapefile({ type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: { big: -123456789.125 }, }); fields[0]; // { type: 'numeric', decimals: 3, width: 14, ... } ``` Cap character columns if file size matters more than long text: ```ts await writeShapefile(data, { maxFieldLength: 50 }); ``` ### Names are truncated and de-duplicated dBase caps names at 11 bytes and expects them to start with a letter. Names are sanitised, truncated, and given a numeric suffix when two collide: | Property | Column | | --- | --- | | `population` | `population` | | `population_density` | `populatio_2` | | `2020_pop` | `F2020_pop` | The mapping is returned so you can surface it: ```ts parts.fields.map((f) => [f.source, f.name]); ``` ### Missing properties become nulls `dbase` treats a record that omits a declared field as an error. Ragged input is padded automatically, so this is fine: ```ts await writeShapefile([ { type: 'Feature', geometry: pointA, properties: { a: 1 } }, { type: 'Feature', geometry: pointB, properties: { b: 'two' } }, ]); // Two columns; each row gets a null for the one it lacks. ``` ### Input with no properties at all A `.dbf` with zero columns is rejected by many GIS readers, so a sequential `FID` column is added rather than writing something unreadable. ## Features with no geometry They are skipped, and counted: ```ts const parts = await writeShapefile(collection); if (parts.skippedCount > 0) { console.warn(`${parts.skippedCount} features had no geometry and were dropped`); } ``` Writing an attribute row with no shape would desynchronise the `.shp` and `.dbf` record numbering — a corruption that shows up much later, in someone else's tool. ## Building the archive yourself When you want the parts *and* an archive without converting twice: ```ts import { writeShapefile, zipParts } from '@crmackey/shapefile-wasm'; const parts = await writeShapefile(data, { epsg: 4326 }); await uploadForPreview(parts.shp); const zip = zipParts(parts, { fileName: 'parcels', level: 9 }); ``` ## Errors Every failure is an `Error` with a message that names the cause, and the feature index where one applies: | Message contains | Cause | | --- | --- | | `single geometry type` | Incompatible geometry types in one input | | `no writable features` | Empty collection, or every feature lacked geometry | | `at least 2 coordinates` | A line with fewer than two vertices | | `at least 3 coordinates` | A polygon ring with fewer than three vertices | | `GeometryCollection` | Not representable in a shapefile | | `not a number` | A malformed coordinate | | `not in the projection table` | An EPSG code that could not be resolved | --- # Reading shapefiles [`readShapefileZip`](/reference/index/functions/readShapefileZip) takes an archive; [`readShapefile`](/reference/index/functions/readShapefile) takes loose components. ## From an archive ```ts import { readShapefileZip } from '@crmackey/shapefile-wasm'; const layers = await readShapefileZip(zipBytes); for (const layer of layers) { console.log(layer.name); // base name, no extension console.log(layer.geojson.features.length); console.log(layer.prj); // WKT, if the archive had one console.log(layer.encoding); // how the .dbf was decoded } ``` Archives in the wild are messy, so the reader is deliberately tolerant: - **Several layers per archive** — each is returned separately, sorted by name. - **Nested folders** — components are grouped by full path, so `a/roads.shp` and `b/roads.shp` stay distinct layers. - **macOS pollution** — `__MACOSX/` resource forks and `.DS_Store` are ignored. - **Orphans** — a `.dbf` with no `.shp` beside it is not treated as a layer. - **Missing `.dbf`** — the layer still reads, with empty properties. An archive with no `.shp` at all throws. ## From loose components ```ts import { readShapefile } from '@crmackey/shapefile-wasm'; const geojson = await readShapefile({ shp: await readFile('roads.shp'), dbf: await readFile('roads.dbf'), // optional cpg: await readFile('roads.cpg', 'utf8'), // optional prj: await readFile('roads.prj', 'utf8'), // optional }); ``` It is only a fixed-width index into the `.shp`, which this reader walks sequentially. It is still written on export, because other software expects it. Without a `.dbf`, every feature comes back with `properties: {}` — still useful for inspecting geometry. ## Geometry comes back idiomatic Single-part geometries are returned as the simple GeoJSON type rather than a one-element collection: | Shapefile | GeoJSON | | --- | --- | | `Polyline`, one part | `LineString` | | `Polyline`, several parts | `MultiLineString` | | `Polygon`, one exterior | `Polygon` | | `Polygon`, several exteriors | `MultiPolygon` | ### Polygon holes are re-nested This is the part that is easy to get wrong. A shapefile stores every ring in one flat list, tagged only outer or inner — the nesting is gone. Rebuilding it by pairing rings in order works right up until an archive lists them differently. Instead, each hole is tested against the exterior rings and assigned to the **smallest** one that contains it. Two adjacent donuts keep their own holes, and a polygon nested inside another gets the right parent. Rings are then rewound to RFC 7946 order: exteriors counter-clockwise, holes clockwise. Shapefiles use the opposite convention. ## Text encoding A `.dbf` carries no encoding of its own. The companion `.cpg` usually names one, but it is often missing, and its spelling is not standardised. Resolution order: 1. An explicit `options.encoding` 2. The `.cpg` contents 3. UTF-8 Legacy single-byte code pages are supported — `cp1252`/`windows-1252`/`latin1`, `cp437`, `cp850`, `cp852`, `cp865`, `cp866`, `cp874`, and the `cp125x` family. Labels are matched generously (`UTF-8`, `utf8`, `65001` all work), and an unrecognised label falls back to UTF-8 rather than failing the read. ```ts // Force it when you know the .cpg is wrong or missing. const layers = await readShapefileZip(bytes, { encoding: 'cp1252' }); ``` dBase pads character columns out to their full width. The underlying reader strips that padding and offers no way to keep it, so values always come back trimmed. ## Measures GeoJSON has no concept of M values, so they are dropped by default. Ask for them and they arrive as a trailing ordinate: ```ts const geojson = await readShapefile({ shp, dbf }, { includeM: true }); // coordinates: [x, y, z, m] ``` ## The projection GeoJSON is defined as WGS 84 and has nowhere to record anything else. When a `.prj` is present its text is carried on a non-standard `wkt` member rather than being discarded: ```ts layers[0].geojson.wkt; // 'PROJCS["NAD_1983_UTM_Zone_15N", ... ]' ``` Coordinates are **not** reprojected — they come back exactly as stored. Use [proj4js](https://github.com/proj4js/proj4js) with that WKT if you need WGS 84. ## Null geometry A shapefile can store a null shape. Those features come back with `geometry: null`, matching GeoJSON. --- # Multiple layers in one archive A shapefile holds exactly one geometry type, so a dataset that mixes points, lines and polygons — a storm network, a parcel package, a survey deliverable — is inherently several files. [`writeLayersZip`](/reference/index/functions/writeLayersZip) converts a batch in one call and packs the results. ```ts import { writeLayersZip } from '@crmackey/shapefile-wasm'; const zip = await writeLayersZip( [ { name: 'StormManholes', geojson: manholes }, { name: 'StormPipes', geojson: pipes }, { name: 'Basins', geojson: basins }, ], { epsg: 26915 }, ); ``` Each layer needs a `name` and a `geojson`. `geojson` accepts everything `writeShapefile` does — a `FeatureCollection`, a lone `Feature`, a bare geometry, an array, or a JSON string. ## Choosing a layout The `layout` option decides how the layers sit inside the archive. Which one you want depends entirely on what is going to open the file. ::: code-group ```text [flat (default)] export.zip ├── StormManholes.shp ├── StormManholes.shx ├── StormManholes.dbf ├── StormManholes.cpg ├── StormManholes.prj ├── StormPipes.shp ├── StormPipes.shx ├── ... └── Basins.prj ``` ```text [folders] export.zip ├── StormManholes/ │ ├── StormManholes.shp │ ├── StormManholes.shx │ ├── StormManholes.dbf │ ├── StormManholes.cpg │ └── StormManholes.prj ├── StormPipes/ │ └── ... └── Basins/ └── ... ``` ```text [nested] export.zip ├── StormManholes.zip ├── StormPipes.zip └── Basins.zip ``` | Layout | Use it when | | --- | --- | | `flat` | Someone will open the archive in QGIS or ArcGIS. The default, and the most widely understood. | | `folders` | There are enough layers that a flat list is unpleasant to read, but you still want one archive. | | `nested` | Each layer has to be handed to something that expects an archive containing exactly one shapefile — which a lot of web GIS uploaders do. | ```ts await writeLayersZip(layers, { layout: 'nested' }); ``` Every inner archive in `nested` is complete and standalone: `.shp`, `.shx`, `.dbf`, `.cpg` and its own `.prj`. You can hand one straight to an uploader, or read it back with `readShapefileZip` on its own. ## A top-level folder `folder` wraps everything, in any layout — useful for a dated or job-numbered export: ```ts await writeLayersZip(layers, { folder: '2026-08-30-storm', layout: 'folders' }); // 2026-08-30-storm/StormPipes/StormPipes.shp ``` ## Shared and per-layer options Options given at the top level apply to every layer; a layer can override any of them. Set the projection once, and let one layer opt out: ```ts await writeLayersZip( [ { name: 'Parcels', geojson: parcels }, { name: 'Aerials', geojson: aerials, epsg: 3857 }, // this one differs ], { epsg: 26915, maxFieldLength: 80 }, ); ``` Anything [`writeShapefile`](/guide/writing) accepts works in both places: `shapeType`, `dimensions`, `maxFieldLength`, `epsg` and `wkt`. ## Names Layer names are sanitised exactly like `fileName` — directory parts and extensions stripped, and characters Windows reserves replaced. Two layers that resolve to the same name are an **error**, not a silent overwrite: ```ts await writeLayersZip([ { name: 'storm pipes', geojson: a }, { name: 'storm:pipes', geojson: b }, ]); // Error: layers "storm pipes" and "storm:pipes" both resolve to "storm_pipes" // inside the archive. Give them distinct names. ``` Both a space and a colon become an underscore, so those collide even though they were written differently. In a flat archive the second would quietly replace the first, and you would ship an archive missing a layer without knowing. ## Errors name the layer A failure in a batch of twenty is useless if it does not say which one: ```ts // Error: layer "StormPipes" could not be written. a shapefile holds a single // geometry type, but the input mixes Polyline and Polygon (feature 3); ... ``` The original error is kept on `cause`. The whole batch fails — no partial archive is produced. An archive that is missing a layer but looks complete is worse than an export that stops and tells you. ## Inspecting before you ship [`writeLayers`](/reference/index/functions/writeLayers) does the conversion without packing, so you can look at what came out: ```ts import { writeLayers, zipLayers } from '@crmackey/shapefile-wasm'; const written = await writeLayers(layers, { epsg: 26915 }); for (const layer of written) { console.log(layer.name, layer.parts.shapeType, layer.parts.featureCount); if (layer.parts.skippedCount > 0) { console.warn(`${layer.name}: ${layer.parts.skippedCount} features had no geometry`); } for (const field of layer.parts.fields) { if (field.source !== field.name) { console.warn(`${layer.name}: "${field.source}" written as "${field.name}"`); } } } const zip = zipLayers(written, { layout: 'folders' }); ``` `name` is the sanitised name used in the archive; `source` is what you passed in. ## Re-packing what you read A layer from `readShapefileZip` already has `name`, `geojson` and `prj`, so it can go straight back in: ```ts const layers = await readShapefileZip(incoming); // Same data, different arrangement. const repacked = await writeLayersZip(layers, { layout: 'nested' }); ``` `prj` is accepted as an alias for `wkt` precisely so this works without renaming a field. --- # Projections The `.prj` file records the coordinate system as ESRI WKT. Without one, software has to guess — and usually guesses WGS 84. ## By EPSG code ```ts await writeShapefileZip(data, { fileName: 'parcels', epsg: 26915 }); ``` Four codes are compiled into the main bundle: | Code | System | | --- | --- | | `4326` | WGS 84 | | `4269` | NAD83 | | `6318` | NAD83 (2011) | | `3857` | WGS 84 / Pseudo-Mercator | Beyond those, a bundled table of **116 definitions** — US UTM zones, NAD83 State Plane, and the global basics — is imported automatically the first time you name a code that is not already loaded. You do not have to do anything to enable it, and bundlers keep it out of your initial chunk because it is a dynamic import. ## Loading the table eagerly [`getProjection`](/reference/index/functions/getProjection) is synchronous, so it only sees what is already loaded. Force the table in if you want to query it: ```ts import { loadProjectionTable, getProjection } from '@crmackey/shapefile-wasm'; await loadProjectionTable(); getProjection(26915); // 'PROJCS["NAD_1983_UTM_Zone_15N", ... ]' ``` ## Custom definitions For a local grid, an in-house system, or anything without an EPSG code: ```ts import { registerProjections } from '@crmackey/shapefile-wasm'; registerProjections({ 900914: 'PROJCS["Hennepin County Grid", ... ]', }); await writeShapefileZip(data, { epsg: 900914 }); ``` Registered definitions also **override** built-in ones, which is useful when your organisation standardises on a particular WKT spelling. For a one-off, skip the registry: ```ts await writeShapefileZip(data, { wkt: 'PROJCS["Site Grid", ... ]' }); ``` `wkt` always beats `epsg`. ## Unknown codes throw ```ts await writeShapefileZip(data, { epsg: 999999 }); // Error: EPSG:999999 is not in the projection table. Supply the definition // yourself with registerProjections({ 999999: '' }), or pass the // WKT directly via { wkt }. ``` This is deliberate. Silently omitting the `.prj` produces data that looks completely fine until someone opens it in the wrong coordinate system — a much worse failure than an export that stops and tells you. ## Where the definitions come from They are scraped from [epsg.io](https://epsg.io) at authoring time by `pnpm run projections`, written into `src/generated/projections.ts`, and **committed**. The published package never makes a network request. A `.prj` fetched at runtime can fail quietly and leave an export with no projection at all, and builds should not depend on a free service staying reachable. To add codes, edit the `CODES` list in `scripts/fetch-projections.mjs` and re-run `pnpm run projections`. ## Fetching from epsg.io For a code outside the bundled table, `fetchProjection` looks one up at runtime using the native `fetch`: ```ts import { fetchProjection, writeShapefileZip } from '@crmackey/shapefile-wasm'; const wkt = await fetchProjection(2027); await writeShapefileZip(data, { fileName: 'parcels', wkt }); ``` Fetch once and register it, and every later export is offline again: ```ts import { fetchProjection, registerProjections } from '@crmackey/shapefile-wasm'; registerProjections({ 2027: await fetchProjection(2027) }); await writeShapefileZip(data, { epsg: 2027 }); ``` ### Formats | Value | URL | What it is | | --- | --- | --- | | `esri-wkt` *(default)* | `epsg.io/.esriwkt` | What a `.prj` expects, and what ArcGIS reads most reliably | | `wkt` | `epsg.io/.wkt` | OGC WKT 1. Widely understood, carries `AUTHORITY` nodes | | `wkt2` | `epsg.io/.wkt2` | ISO 19162 WKT 2. The modern standard; older GIS cannot read it | ```ts const wkt2 = await fetchProjection(26915, { format: 'wkt2' }); ``` WKT 2 is the better standard, but a `.prj` containing it will confuse a lot of GIS software. Use `wkt2` for display or for handing to another library — not for `registerProjections`. ### Failure handling Every failure throws an `Error` that names what went wrong: | Situation | Message | | --- | --- | | Code does not exist | `epsg.io returned 404 Not Found for EPSG:999999` | | Service unreachable | `could not reach https://epsg.io … usually CORS or an offline network` | | Timed out | `the request for EPSG:4326 timed out after 10000ms` | | Aborted by the caller | `the request for EPSG:4326 was aborted` | | A proxy answered instead | `the body is not a projection … check for a proxy` | The original failure is preserved on `cause`. That last one is worth knowing about: on a corporate network a captive portal or proxy can answer with its own page and a 200 status. Writing that HTML into a `.prj` fails silently until someone opens the file in a GIS, so the body is checked as well as the status. ### Options ```ts await fetchProjection(26915, { format: 'esri-wkt', // default timeoutMs: 10_000, // default; 0 disables signal: controller.signal, baseUrl: 'https://epsg.io', // a mirror, or a CORS proxy fetch: customFetch, // for tests or a custom agent }); ``` This is a cross-origin request. epsg.io sends permissive CORS headers, but if your Content Security Policy restricts `connect-src`, add `https://epsg.io` to it. Behind a strict proxy, point `baseUrl` at your own mirror. ## What this does not do It writes the `.prj` you ask for. It does not reproject coordinates — pair it with [proj4js](https://github.com/proj4js/proj4js) if the numbers themselves need converting. --- # In the browser Everything that touches the DOM lives in `@crmackey/shapefile-wasm/browser`, so the core stays importable from Node, workers and SSR. ```ts import { downloadShapefileZip, readShapefileFile, triggerDownload, } from '@crmackey/shapefile-wasm/browser'; ``` ## Exporting to a download ```ts exportButton.addEventListener('click', async () => { await downloadShapefileZip(featureCollection, { fileName: 'survey-points', epsg: 26915, }); }); ``` The archive bytes are returned as well, so you can upload the same file without building it twice: ```ts const zip = await downloadShapefileZip(data, { fileName: 'parcels' }); await fetch('/api/archive', { method: 'POST', body: zip }); ``` ## Importing from a file picker ```ts const input = document.querySelector('input[type=file]')!; input.addEventListener('change', async () => { const file = input.files?.[0]; if (!file) return; const layers = await readShapefileFile(file); for (const layer of layers) { map.addSource(layer.name, { type: 'geojson', data: layer.geojson }); } }); ``` Works with a `File` from a picker, a `File` from a drop event, or any `Blob`. ### Drag and drop ```ts dropZone.addEventListener('drop', async (event) => { event.preventDefault(); const file = event.dataTransfer?.files[0]; if (!file) return; const layers = await readShapefileFile(file); }); ``` ## Saving arbitrary bytes ```ts triggerDownload(bytes, 'export.zip'); triggerDownload(pngBytes, 'map.png', 'image/png'); ``` It creates a temporary object URL, clicks a hidden anchor, removes it, and revokes the URL on the next tick — revoking synchronously cancels the download in some browsers. Outside a browser it throws with a message pointing at `fs.writeFile`, rather than failing in some confusing way. ## Keeping the UI responsive Conversion is synchronous inside WebAssembly. A few thousand features is fast, but a very large dataset will block the main thread. Move it to a worker: ::: code-group ```ts [worker.ts] import { writeShapefileZip } from '@crmackey/shapefile-wasm'; self.onmessage = async (event) => { const zip = await writeShapefileZip(event.data.geojson, event.data.options); self.postMessage(zip, [zip.buffer]); }; ``` ```ts [main.ts] import { triggerDownload } from '@crmackey/shapefile-wasm/browser'; const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', }); worker.onmessage = (event) => triggerDownload(event.data, 'export.zip'); worker.postMessage({ geojson, options: { fileName: 'export', epsg: 4326 } }); ``` The core entry point works unmodified in a worker — no DOM, no configuration. ## Framework notes **Next.js / SSR.** Import the core anywhere; import `/browser` only in client components. The core never references `document` or `window`. **Vite.** Nothing to configure for the root entry. For `/slim`, `?url` gives you the asset URL as shown in [Entry points](/guide/entry-points). **Content Security Policy.** WebAssembly compilation needs `'wasm-unsafe-eval'` in your `script-src`. This is a distinct, narrower permission than `'unsafe-eval'` and does not enable JavaScript `eval`. --- # Format mapping How GeoJSON concepts land in the shapefile format, and back. ## Geometry types | GeoJSON | Shapefile | Notes | | --- | --- | --- | | `Point` | `Point` | | | `MultiPoint` | `Multipoint` | | | `LineString` | `Polyline` | One part | | `MultiLineString` | `Polyline` | Several parts | | `Polygon` | `Polygon` | Exterior ring plus holes | | `MultiPolygon` | `Polygon` | All rings flattened into one record | | `GeometryCollection` | — | Not representable; rejected | Reading back, single-part geometry returns the simple type: a one-part `Polyline` becomes a `LineString`, not a one-element `MultiLineString`. ## Mixing types A shapefile holds exactly one geometry type. | Input | Result | | --- | --- | | `Point` + `MultiPoint` | Promoted to `Multipoint` | | `LineString` + `MultiLineString` | Both are `Polyline` already | | `Polygon` + `MultiPolygon` | Both are `Polygon` already | | Anything else mixed | Error naming the feature index | ## Dimensions | Coordinate | Detected | Shape type | | --- | --- | --- | | `[x, y]` | `xy` | `Point`, `Polyline`, … | | `[x, y, z]` | `xyz` | `PointZ`, `PolylineZ`, … | | `[x, y, z, m]` | `xyzm` | `PointZ` with measures | Detection uses the richest coordinate anywhere in the input. Override with `dimensions`; `'xym'` reads the third ordinate as a measure instead of Z. Reading back, Z is emitted as a third ordinate. M is dropped unless `includeM: true`, because GeoJSON has no concept of measures. ## Attribute types Writing: | Property values | dBase column | | --- | --- | | Numbers only | `numeric`, width and decimals measured from the data | | Booleans only | `logical` | | Strings only | `character`, width = longest value | | Objects or arrays | `character`, JSON-encoded | | Mixed types | `character` | | All `null` | `character`, width 1 | | No properties at all | A synthetic `FID` `numeric` column | Reading: | dBase column | JSON value | | --- | --- | | `character` | `string`, trimmed; `null` when empty | | `numeric`, `float`, `double`, `currency` | `number`, or `null` | | `logical` | `boolean`, or `null` | | `integer` | `number` | | `date` | `string`, `"YYYY-MM-DD"` | | `datetime` | `string`, ISO 8601 | | `memo` | `string` | ## Field names dBase caps names at 11 bytes and expects a leading letter. | Property | Column | Why | | --- | --- | --- | | `name` | `name` | Fits | | `population_density` | `populatio_2` | Truncated, then de-duplicated | | `2020_pop` | `F2020_pop` | Prefixed — cannot start with a digit | | `my property!` | `my_propert` | Non-alphanumerics replaced, then truncated | The full mapping is in `parts.fields`. ## Encoding The `.dbf` is always written as UTF-8, with a `.cpg` saying so. On read, the `.cpg` decides, falling back to UTF-8; legacy single-byte code pages are supported. ## Ring winding | Format | Exterior | Holes | | --- | --- | --- | | Shapefile | Clockwise | Counter-clockwise | | GeoJSON (RFC 7946) | Counter-clockwise | Clockwise | Converted automatically in both directions. Rings are closed if they are not. ## Record alignment The `.shp` and `.dbf` are matched by position — record *n* of one belongs to record *n* of the other. Nothing links them by id. That is why features with `null` geometry are skipped entirely rather than written as an attribute row with no shape: one such row would shift every subsequent pairing. --- # Known limits Most of these come from the shapefile format itself rather than this package. ## Format limits **One geometry type per file.** Split mixed data into several files, one per type. **`GeometryCollection` cannot be represented.** It is rejected rather than silently flattened. **Field names are capped at 11 bytes.** Check `parts.fields` to see what was renamed. **Character columns are capped at 254 bytes.** Longer strings are truncated on a character boundary, never mid-codepoint. **A `.shp` cannot exceed 2 GB.** The header stores its length in 16-bit words as a signed 32-bit integer. Very large exports need splitting. **No topology, no styling, no nested attributes.** Objects and arrays are JSON-encoded into text columns. ## Limits of this package **`Multipatch` is read-only.** Triangle strips and fans are expanded into a `MultiPolygon`, which is lossy but preserves the surface. Writing is not supported. **Text is always trimmed on read.** dBase pads character columns to a fixed width; the underlying reader strips that padding and offers no way to keep it. **Measures are dropped by default.** GeoJSON has no place for them. Pass `includeM: true` to receive them as a trailing ordinate. **No reprojection.** The `.prj` you ask for is written verbatim; coordinates are never transformed. Use [proj4js](https://github.com/proj4js/proj4js) for that. **No streaming.** Everything is held in memory. Large datasets are limited by the WebAssembly heap — see [Troubleshooting](/guide/troubleshooting). **Memo fields are not written.** They can be read. ## Deliberate design choices These look like limits but are decisions: **An unknown EPSG code throws.** Silently omitting the `.prj` produces data that looks fine until someone opens it in the wrong coordinate system. **Features with no geometry are dropped, not written as null shapes.** Keeps the `.shp` and `.dbf` record numbering aligned. **Attribute-less input gets a synthetic `FID` column.** A `.dbf` with zero columns is rejected by many readers. **Numeric columns are sized from the data.** A guessed width would let `dbase` silently crop values into different numbers. --- # Troubleshooting ## "a shapefile holds a single geometry type" Your input mixes types a shapefile cannot store together. The message names the offending feature index. Split by type: ```ts const byType = new Map(); for (const feature of collection.features) { const key = feature.geometry?.type ?? 'null'; byType.set(key, [...(byType.get(key) ?? []), feature]); } for (const [type, features] of byType) { await writeShapefileZip({ type: 'FeatureCollection', features }, { fileName: `export-${type.toLowerCase()}`, }); } ``` `Point` + `MultiPoint` is the one mix that is allowed — it is promoted to `Multipoint` rather than failing. ## "the input contains no writable features" The collection was empty, or every feature had `geometry: null`. Check `skippedCount` on a successful call to see how many were dropped. ## "EPSG:… is not in the projection table" The code is not in the 116-entry bundled table. Register it: ```ts registerProjections({ 2027: 'PROJCS["…"]' }); ``` Grab the WKT from `https://epsg.io/.esriwkt`, or pass it inline with `{ wkt }`. ## Attributes are truncated dBase columns are fixed-width. Widths are measured from your data, so truncation means either the 254-byte character ceiling, or a `maxFieldLength` you set. Check what was allocated: ```ts parts.fields.filter((f) => f.width === 254); ``` ## Field names look wrong dBase caps names at 11 bytes. `parts.fields` reports every rename: ```ts parts.fields.filter((f) => f.source !== f.name); ``` Rename properties before export if the truncated names are unclear. ## Text comes back as mojibake The `.dbf` was written in a legacy code page and the `.cpg` is missing or wrong. Force it: ```ts await readShapefileZip(bytes, { encoding: 'cp1252' }); ``` `cp1252` covers most Western-European files from older ArcGIS versions; `cp437` and `cp850` show up in genuinely old data. ## Polygons render inside out Ring winding is corrected automatically in both directions. If something still looks wrong, check whether the source rings were tagged correctly as outer or inner — some writers get this wrong, and a hole with no containing exterior is attached to the first ring as a fallback. ## "RuntimeError: unreachable" A panic inside WebAssembly. Turn on the panic hook for a real message: ```ts import { load } from '@crmackey/shapefile-wasm'; // (development only) ``` Or from the generated bindings directly: ```ts import initWasm, { setPanicHook } from '@crmackey/shapefile-wasm/pkg/shapefile_wasm.js'; await initWasm(); setPanicHook(); ``` Then re-run. The message and a Rust stack go to `console.error`. Please [open an issue](https://github.com/CalebM1987/shapefile-wasm/issues) with it — a panic is a bug, not a supported failure mode. ## Out of memory on a large dataset Everything is held in memory, and WebAssembly's heap is smaller than Node's. Split the work: ```ts const CHUNK = 50_000; for (let i = 0; i < features.length; i += CHUNK) { const part = features.slice(i, i + CHUNK); await writeShapefileZip({ type: 'FeatureCollection', features: part }, { fileName: `export-${i / CHUNK}`, }); } ``` ## The build fails on bulk-memory operations You are building from source with a `wasm-opt` older than the features the Rust toolchain emits. The flags in `Cargo.toml` under `[package.metadata.wasm-pack.profile.release]` exist for this — see [Development](/guide/development). ## "time not implemented on this platform" `dbase` stamps the `.dbf` header with the current date, which reaches `SystemTime::now()` — unimplemented on `wasm32-unknown-unknown`. The `time` dependency with its `wasm-bindgen` feature fixes it; see [Development](/guide/development). If you see this, that dependency has been dropped from `Cargo.toml`. ## CSP blocks the module WebAssembly compilation needs `'wasm-unsafe-eval'` in `script-src`. It is a narrower permission than `'unsafe-eval'` and does not enable JavaScript `eval`. --- # Architecture ## The split ``` GeoJSON ──► serde_json ──► geometry + schema resolution ──► shapefile / dbase crates │ .shp .shx .dbf bytes │ TypeScript: .cpg, .prj, zip ``` The Rust core deliberately knows nothing about zip files, projections or browsers. It converts between GeoJSON and shapefile bytes and stops there. That keeps the WebAssembly binary small, and leaves the core usable as a plain Rust crate. Everything above the line is ordinary TypeScript: `fflate` for zipping, a projection registry for the `.prj`, and an optional DOM layer. ## Layout ``` rust/ Rust core, compiled to WebAssembly lib.rs wasm-bindgen entry points, the write pipeline read.rs shapefile -> GeoJSON, ring nesting, encodings input.rs GeoJSON parsing, geometry-type resolution schema.rs .dbf schema inference, field naming geometry.rs GeoJSON -> concrete shapefile shape types error.rs error types src/ TypeScript layer index.ts main entry; registers the inlined wasm loader slim.ts same API, no embedded binary api.ts the surface both entry points share write.ts writeShapefile, writeShapefileZip, zipParts read.ts readShapefile, readShapefileZip projections.ts EPSG registry, lazy table loading browser.ts DOM-only helpers types.ts public types generated/ build output scripts/ build helpers test/ vitest suites docs/ this site ``` ## Why the generics in geometry.rs The `shapefile` crate has no single "geometry" type. Each dimensionality is a separate Rust type — `Polyline`, `PolylineM`, `PolylineZ` — so the conversion is generic over the point type and `lib.rs` picks the concrete one: ```rust match (family, dimension) { (Family::Polyline, Dimension::Xy) => emit!(to_polyline, Point), (Family::Polyline, Dimension::Xym) => emit!(to_polyline, PointM), (Family::Polyline, Dimension::Xyz | Dimension::Xyzm) => emit!(to_polyline, PointZ), // … } ``` The `match` is exhaustive, so adding a `Dimension` variant produces a compile error at every site that has to handle it. ## Why the schema needs a whole pass `dbase` crops any value that overruns its column, silently. Deciding column widths therefore requires seeing every value first — which is why `Schema::infer` walks all features before a single byte is written. For numeric columns it goes further: it renders each value at a candidate precision and measures the result, dropping precision if the widest value would not fit, and falling back to text if it still cannot. ## Why reading rebuilds nesting A shapefile polygon is a flat list of rings tagged only outer or inner. GeoJSON needs the nesting back. Pairing rings in order is the obvious approach and it is wrong the moment an archive lists them differently. Instead each hole is tested against every exterior with a ray-casting point-in-polygon check, and assigned to the **smallest** ring that contains it — which also gets nested polygons right. ## Why the wasm binary is inlined The root entry embeds the binary as base64 so the package works with no bundler configuration in any environment. That costs about a third more bytes and a decode step. `/slim` exists for the other trade: serve the `.wasm` yourself, and the browser streams and compiles it in parallel. The blob is behind a dynamic import, so `/slim` consumers never download it. ## Why the projection table is scraped at authoring time `scripts/fetch-projections.mjs` reads epsg.io and writes a committed TypeScript file. It is not part of the build. A runtime lookup can fail quietly and produce an export with no projection at all — data that looks correct until someone opens it in the wrong coordinate system. Builds should also not depend on a free service staying reachable. The table is a dynamic import loaded on a cache miss, so the 60 KB is only paid by projects that use a code outside the four built-ins. ## Error handling Rust errors are a `thiserror` enum carrying the feature index where one applies, converted to a JavaScript `Error` at the boundary. There are no error codes — messages are meant to be read, and they name what to do about the problem. `panic = "abort"` keeps the binary small, so a panic is an opaque `RuntimeError: unreachable`. Every known panic path in the underlying crates — short polyline parts, empty ring lists — is guarded ahead of time and returned as a proper error instead. A panic that escapes is a bug. --- # Development ## Prerequisites - **Rust** 1.85+ with the `wasm32-unknown-unknown` target. Edition 2024 is required by `dbase`. - **wasm-pack** - **Node** 18+ - **pnpm** 11+ (`corepack enable pnpm`, or install it however you prefer) ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ --target wasm32-unknown-unknown curl -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh git clone https://github.com/CalebM1987/shapefile-wasm cd shapefile-wasm pnpm install pnpm run build ``` ## The workspace This is a pnpm workspace. The library is the root package; `demo/` is a member that links it with `workspace:*`. One `pnpm install` at the root covers both, and there is a single `pnpm-lock.yaml` — the demo does not have its own. That linkage is why the demo always builds against your working tree rather than a published copy, and why `pnpm run build` has to run before the demo does. pnpm refuses to run dependency install scripts unless they are approved in `pnpm-workspace.yaml`. Only `esbuild` is, for the platform binary Vite and Vitest need. If a dependency update makes pnpm ask about a new one, look at what the script actually does before running `pnpm approve-builds`. ## Scripts | Script | Does | | --- | --- | | `pnpm run build` | The three build stages below, in order | | `pnpm run build:wasm` | `wasm-pack` compiles `rust/` into `pkg/` | | `pnpm run build:inline` | Embeds the `.wasm` as base64, copies the glue into `src/generated/` | | `pnpm run build:ts` | `tsc` into `dist/`, then copies the runtime glue across | | `pnpm run typecheck` | Type-check without emitting | | `pnpm run test` | Rust tests, a build, then the TypeScript suite | | `pnpm run docs:dev` | This site, with hot reload | | `pnpm run projections` | Re-scrape the EPSG table from epsg.io | | `pnpm run clean` | Remove all build output and generated sources | ## Two load-bearing bits of Cargo.toml Both of these look like noise and are not. Removing either breaks the build in a way whose error message points somewhere else entirely. ### wasm-opt feature flags ```toml [package.metadata.wasm-pack.profile.release] wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int", ...] ``` The `wasm-opt` binary that wasm-pack downloads defaults to an older feature set than the current Rust toolchain emits. Without these flags the build fails validation: ``` [wasm-validator error] unexpected false: Bulk memory operations require bulk memory [--enable-bulk-memory] ``` Every feature listed is baseline in all browsers since 2021 and in Node 16. ### The `time` dependency ```toml [target.'cfg(target_arch = "wasm32")'.dependencies] time = { version = "0.3", default-features = false, features = ["wasm-bindgen"] } ``` `dbase` stamps the `.dbf` header with today's date. That reaches `SystemTime::now()`, which is unimplemented on `wasm32-unknown-unknown` and **aborts the whole module** — surfacing in JavaScript as a bare `RuntimeError: unreachable` with no hint of the cause. The `wasm-bindgen` feature makes `time` read the clock from JavaScript instead. This crate does not use `time` directly; the dependency exists purely so Cargo's feature unification applies it to `dbase`'s copy. ## Debugging a panic `panic = "abort"` in the release profile means panics surface as `RuntimeError: unreachable`. To get a real message: ```bash wasm-pack build --dev --target web --out-dir pkg --out-name shapefile_wasm ``` ```js import init, { setPanicHook } from './pkg/shapefile_wasm.js'; await init({ module_or_path: await readFile('./pkg/shapefile_wasm_bg.wasm') }); setPanicHook(); ``` The panic message and a demangled Rust stack then go to `console.error`. ## Regenerating the projection table ```bash pnpm run projections ``` Scrapes [epsg.io](https://epsg.io) for the codes listed in `scripts/fetch-projections.mjs` and rewrites `src/generated/projections.ts`. This is **not** part of the build, and the result is committed. The published package must never depend on that service being reachable, and a `.prj` that changes silently between builds would be worse than one that is missing. ## Adding a geometry type 1. Add the variant in `rust/input.rs` (`Geometry`, `Family`). 2. Add the conversion in `rust/geometry.rs`. 3. Add the arm to the `match` in `write_geometry` in `rust/lib.rs`. 4. Add the reverse in `shape_to_geometry` in `rust/read.rs`. 5. Add a round-trip test. The `match` in `write_geometry` is exhaustive over `(Family, Dimension)`, so the compiler will point at what is missing. ## Code style Rust is `rustfmt` default. TypeScript is 2-space, single quotes, trailing commas, 100-column soft wrap. Comments explain *why*, not *what* — most of the non-obvious code in this repository exists to work around a specific sharp edge in the shapefile or dBase format, and that reason is worth recording. --- # Testing ```bash pnpm test ``` Runs the Rust tests, then a full build, then the TypeScript suite. ## Running them individually ```bash pnpm run test:rust # cargo test pnpm run test:ts # vitest run pnpm run test:watch # vitest, watch mode pnpm run test:coverage # vitest with a v8 coverage report ``` The TypeScript suites import from `src/`, so no build is needed while iterating. `test/package.test.ts` is the exception — it imports the built `dist/` and skips itself when `dist/` is absent. ## What is covered | File | Covers | | --- | --- | | `rust/*.rs` (`#[cfg(test)]`) | Conversion rules, schema inference, field naming, ring nesting | | `test/write.test.ts` | File structure, header fields, schema inference, error messages | | `test/roundtrip.test.ts` | Write-then-read equivalence across geometry, attributes and encodings | | `test/zip.test.ts` | Archive contents, multi-layer and nested archives, pollution | | `test/projections.test.ts` | The registry, lazy table loading, `.prj` resolution | | `test/browser.test.ts` | DOM helpers under happy-dom | | `test/wasm.test.ts` | Init lifecycle, the `/slim` entry, memory behaviour | | `test/package.test.ts` | The built package and every declared entry point | ## Round-trips are the real test `test/roundtrip.test.ts` writes bytes and reads them straight back. Without binary fixtures from other software, that is the only real evidence that what this package emits is what a reader understands. Some of them are deliberately adversarial. This one would pass even if holes were paired with exteriors by index, so the fixture puts two donuts 100 units apart and asserts each hole came back attached to its *own* square: ```ts for (const polygon of geometry.coordinates) { expect(polygon).toHaveLength(2); const exteriorX = polygon[0][0][0]; const holeX = polygon[1][0][0]; expect(Math.abs(exteriorX - holeX)).toBeLessThan(50); } ``` ## Byte-level assertions Structural tests check the actual bytes rather than trusting the writer: ```ts // 9994, big-endian, at byte 0 — the shapefile magic number. expect(readInt32BE(parts.shp, 0)).toBe(9994); // Byte 24 is the file length in 16-bit words. expect(readInt32BE(parts.shp, 24) * 2).toBe(parts.shp.length); // The .shx is a 100-byte header plus one 8-byte entry per record. expect(parts.shx.length).toBe(100 + 3 * 8); ``` ## Writing a new test Fixtures live in `test/fixtures.ts`. Prefer adding to the round-trip suite: assert on the GeoJSON that comes back, not on intermediate state. ```ts import { describe, expect, it } from 'vitest'; import { readShapefile, writeShapefile } from '../src/index.js'; it('preserves the thing I care about', async () => { const parts = await writeShapefile(input); const output = await readShapefile({ shp: parts.shp, dbf: parts.dbf }); expect(output.features[0].properties.value).toBe(expected); }); ``` For Rust-side logic, a unit test in the relevant module is faster and gives a better failure message than reaching through WebAssembly. ## Coverage ```bash pnpm run test:coverage ``` Generated sources and `src/types.ts` are excluded — the first is machine-written, the second is types only.