Skip to main content

shapefile_wasm/
lib.rs

1// This crate parses untrusted input — a .shp or .dbf from an unknown source is
2// attacker-controlled data. Enforce that none of the parsing is written with
3// raw pointers, so a malformed file can only ever produce an error.
4#![forbid(unsafe_code)]
5
6//! Reads and writes ESRI Shapefiles from GeoJSON, entirely in memory.
7//!
8//! The public surface is deliberately small: hand it GeoJSON, get back the raw
9//! `.shp` / `.shx` / `.dbf` byte buffers — or hand it those bytes and get GeoJSON
10//! back. Zipping, projection files and anything browser-shaped is left to the
11//! TypeScript layer, which keeps the wasm binary small and this core usable from
12//! plain Rust too.
13//!
14//! # Entry points
15//!
16//! * [`write_shapefile`] / [`write_shapefile_from_json`] — GeoJSON to bytes
17//! * [`read_shapefile`] / [`read_shapefile_to_json`] — bytes to GeoJSON
18//! * [`set_panic_hook`] — route panics to `console.error` while debugging
19
20use std::io::Cursor;
21
22use serde::{Deserialize, Serialize};
23use wasm_bindgen::prelude::*;
24
25// Public so the crate is usable directly from Rust, not only through the wasm
26// bindings — and so the rustdoc links below resolve.
27pub mod error;
28pub mod geometry;
29pub mod input;
30pub mod read;
31pub mod schema;
32
33use error::{Result, ShapefileError};
34use geometry::Dimension;
35use input::{Family, Feature};
36use schema::{FieldKind, Schema, MAX_CHARACTER_WIDTH};
37use shapefile::record::EsriShape;
38use shapefile::{Point, PointM, PointZ, ShapeWriter};
39
40/// Caller-tunable knobs. Every field is optional; the defaults suit most data.
41#[derive(Debug, Default, Deserialize)]
42#[serde(default, rename_all = "camelCase")]
43pub struct Options {
44    /// Force a geometry family (`point`, `multipoint`, `polyline`, `polygon`)
45    /// instead of inferring it from the input.
46    pub shape_type: Option<String>,
47    /// Force the dimensionality (`xy`, `xym`, `xyz`, `xyzm`). Defaults to
48    /// whatever the coordinates actually carry.
49    pub dimensions: Option<String>,
50    /// Upper bound on character field width, 1-254.
51    pub max_field_length: Option<usize>,
52}
53
54/// What one column became in the .dbf, so callers can report renames.
55#[derive(Debug, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct FieldReport {
58    /// The original GeoJSON property name.
59    pub source: String,
60    /// The name actually written to the .dbf.
61    pub name: String,
62    /// One of `character`, `numeric`, `logical`.
63    #[serde(rename = "type")]
64    pub kind: &'static str,
65    /// Column width in bytes, sized to the widest value actually present.
66    pub width: u32,
67    /// Decimal places, for numeric columns. Zero otherwise.
68    pub decimals: u32,
69}
70
71/// The generated shapefile components, plus what was inferred along the way.
72///
73/// Every getter copies its data out of wasm linear memory, so the returned
74/// buffers stay valid after the object is freed.
75#[wasm_bindgen]
76#[derive(Debug)]
77pub struct ShapefileParts {
78    shp: Vec<u8>,
79    shx: Vec<u8>,
80    dbf: Vec<u8>,
81    shape_type: String,
82    dimensions: String,
83    feature_count: usize,
84    skipped_count: usize,
85    bbox: Vec<f64>,
86    fields: Vec<FieldReport>,
87}
88
89#[wasm_bindgen]
90impl ShapefileParts {
91    /// Contents of the `.shp`: the geometry itself.
92    #[wasm_bindgen(getter)]
93    pub fn shp(&self) -> Vec<u8> {
94        self.shp.clone()
95    }
96
97    /// Contents of the `.shx`: a fixed-width index into the `.shp`. Not needed
98    /// to read the file back, but GIS software expects it to be present.
99    #[wasm_bindgen(getter)]
100    pub fn shx(&self) -> Vec<u8> {
101        self.shx.clone()
102    }
103
104    /// Contents of the `.dbf`: the attribute table, written as UTF-8.
105    #[wasm_bindgen(getter)]
106    pub fn dbf(&self) -> Vec<u8> {
107        self.dbf.clone()
108    }
109
110    /// Contents of the companion `.cpg` file. The `.dbf` is written as UTF-8.
111    #[wasm_bindgen(getter)]
112    pub fn cpg(&self) -> String {
113        "UTF-8".to_string()
114    }
115
116    /// The ESRI shape type that was written, e.g. `PolygonZ`.
117    #[wasm_bindgen(getter, js_name = shapeType)]
118    pub fn shape_type(&self) -> String {
119        self.shape_type.clone()
120    }
121
122    /// Which ordinates were written: `xy`, `xym`, `xyz` or `xyzm`.
123    #[wasm_bindgen(getter)]
124    pub fn dimensions(&self) -> String {
125        self.dimensions.clone()
126    }
127
128    /// How many features made it into the file.
129    #[wasm_bindgen(getter, js_name = featureCount)]
130    pub fn feature_count(&self) -> usize {
131        self.feature_count
132    }
133
134    /// How many input features were dropped for having no geometry.
135    #[wasm_bindgen(getter, js_name = skippedCount)]
136    pub fn skipped_count(&self) -> usize {
137        self.skipped_count
138    }
139
140    /// `[minX, minY, maxX, maxY]`.
141    #[wasm_bindgen(getter)]
142    pub fn bbox(&self) -> Vec<f64> {
143        self.bbox.clone()
144    }
145
146    /// The resolved .dbf schema, including any field renames.
147    #[wasm_bindgen(getter)]
148    pub fn fields(&self) -> std::result::Result<JsValue, JsValue> {
149        let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
150        self.fields
151            .serialize(&serializer)
152            .map_err(|e| JsError::new(&e.to_string()).into())
153    }
154}
155
156/// Builds a shapefile from a GeoJSON value passed straight across the wasm
157/// boundary.
158///
159/// A shapefile holds exactly one geometry type, so mixed input is rejected —
160/// except `Point` and `MultiPoint`, which are promoted to a single `Multipoint`
161/// file. Dimensionality follows the coordinates unless `options.dimensions`
162/// overrides it, and the `.dbf` schema is inferred from every feature's
163/// properties.
164///
165/// # Arguments
166///
167/// * `geojson` - A `FeatureCollection`, a lone `Feature`, a bare geometry, or an
168///   array of any of those.
169/// * `options` - An [`Options`] object, or `null`/`undefined` for the defaults.
170///
171/// # Errors
172///
173/// Returns a JavaScript `Error` when the input is not valid GeoJSON, mixes
174/// incompatible geometry types, holds no writable features, contains a
175/// malformed coordinate or a degenerate ring, or uses `GeometryCollection`.
176#[wasm_bindgen(js_name = writeShapefile)]
177pub fn write_shapefile(
178    geojson: JsValue,
179    options: JsValue,
180) -> std::result::Result<ShapefileParts, JsValue> {
181    let value: serde_json::Value = serde_wasm_bindgen::from_value(geojson)
182        .map_err(|e| ShapefileError::Input(e.to_string()))?;
183    Ok(build(&value, &parse_options(options)?)?)
184}
185
186/// Builds a shapefile from a GeoJSON string.
187///
188/// Identical to [`write_shapefile`] but for the input: the text is parsed in
189/// Rust, avoiding a `JSON.parse` in JavaScript when the data arrived as text in
190/// the first place.
191///
192/// # Arguments
193///
194/// * `geojson` - Serialised GeoJSON.
195/// * `options` - An [`Options`] object, or `null`/`undefined` for the defaults.
196///
197/// # Errors
198///
199/// Returns a JavaScript `Error` when the string is not valid JSON, or for any
200/// reason [`write_shapefile`] would fail.
201#[wasm_bindgen(js_name = writeShapefileFromJson)]
202pub fn write_shapefile_from_json(
203    geojson: &str,
204    options: JsValue,
205) -> std::result::Result<ShapefileParts, JsValue> {
206    let value: serde_json::Value =
207        serde_json::from_str(geojson).map_err(|e| ShapefileError::Input(e.to_string()))?;
208    Ok(build(&value, &parse_options(options)?)?)
209}
210
211/// Reads a shapefile back into a GeoJSON `FeatureCollection`.
212///
213/// The `.shx` is not needed; the `.shp` is walked sequentially. Polygon rings
214/// are re-nested — each hole is matched to the smallest ring containing it —
215/// and rewound to RFC 7946 winding order.
216///
217/// # Arguments
218///
219/// * `shp` - Contents of the `.shp`.
220/// * `dbf` - Contents of the `.dbf`. Optional: without it, features come back
221///   with empty properties, which is still useful for inspecting geometry.
222/// * `options` - A [`read::ReadOptions`] object, or `null`/`undefined`.
223///
224/// # Errors
225///
226/// Returns a JavaScript `Error` when the `.shp` is truncated or malformed, or
227/// holds a shape type this reader does not understand.
228#[wasm_bindgen(js_name = readShapefile)]
229pub fn read_shapefile(
230    shp: &[u8],
231    dbf: Option<Vec<u8>>,
232    options: JsValue,
233) -> std::result::Result<JsValue, JsValue> {
234    let options = parse_read_options(options)?;
235    let geojson = read::read(shp, dbf.as_deref(), &options)?;
236
237    // `serialize_missing_as_null` matters: without it a null attribute arrives in
238    // JavaScript as `undefined`, which disappears from `JSON.stringify` output
239    // and breaks round-tripping through a file.
240    let serializer = serde_wasm_bindgen::Serializer::new()
241        .serialize_maps_as_objects(true)
242        .serialize_missing_as_null(true);
243
244    geojson
245        .serialize(&serializer)
246        .map_err(|e| JsError::new(&e.to_string()).into())
247}
248
249/// Reads a shapefile and returns GeoJSON as a string.
250///
251/// Identical to [`read_shapefile`] but skips building JavaScript objects, which
252/// is cheaper when the result is headed straight for a file or the network
253/// rather than for code that will walk it.
254///
255/// # Arguments
256///
257/// * `shp` - Contents of the `.shp`.
258/// * `dbf` - Contents of the `.dbf`, optional.
259/// * `options` - A [`read::ReadOptions`] object, or `null`/`undefined`.
260///
261/// # Errors
262///
263/// Returns a JavaScript `Error` for the same reasons as [`read_shapefile`], or
264/// if the result cannot be serialised.
265#[wasm_bindgen(js_name = readShapefileToJson)]
266pub fn read_shapefile_to_json(
267    shp: &[u8],
268    dbf: Option<Vec<u8>>,
269    options: JsValue,
270) -> std::result::Result<String, JsValue> {
271    let options = parse_read_options(options)?;
272    let geojson = read::read(shp, dbf.as_deref(), &options)?;
273    serde_json::to_string(&geojson).map_err(|e| JsError::new(&e.to_string()).into())
274}
275
276fn parse_read_options(options: JsValue) -> std::result::Result<read::ReadOptions, JsValue> {
277    if options.is_undefined() || options.is_null() {
278        return Ok(read::ReadOptions::default());
279    }
280    serde_wasm_bindgen::from_value(options)
281        .map_err(|e| JsError::new(&format!("invalid options: {e}")).into())
282}
283
284/// Routes Rust panics to `console.error` with a readable message and stack.
285///
286/// A panic in wasm otherwise surfaces as a bare `RuntimeError: unreachable`,
287/// which says nothing about what went wrong. Call this once at startup while
288/// debugging; it is not needed in production.
289#[wasm_bindgen(js_name = setPanicHook)]
290pub fn set_panic_hook() {
291    console_error_panic_hook::set_once();
292}
293
294fn parse_options(options: JsValue) -> std::result::Result<Options, JsValue> {
295    if options.is_undefined() || options.is_null() {
296        return Ok(Options::default());
297    }
298    serde_wasm_bindgen::from_value(options)
299        .map_err(|e| JsError::new(&format!("invalid options: {e}")).into())
300}
301
302/// The whole pipeline: parse, resolve types, infer the schema, write bytes.
303fn build(value: &serde_json::Value, options: &Options) -> Result<ShapefileParts> {
304    let features = input::normalize(value)?;
305    let total = features.len();
306
307    // Features without geometry cannot be represented, so they are dropped
308    // wholesale — writing an attribute row with no shape would desynchronise the
309    // .shp and .dbf record numbering.
310    let kept: Vec<&Feature> = features
311        .iter()
312        .filter(|feature| feature.geometry.is_some())
313        .collect();
314
315    if kept.is_empty() {
316        return Err(ShapefileError::Empty);
317    }
318    let skipped_count = total - kept.len();
319
320    let family = resolve_family(&kept, options)?;
321    let dimension = resolve_dimension(&kept, options)?;
322
323    let max_field_length = options
324        .max_field_length
325        .unwrap_or(MAX_CHARACTER_WIDTH)
326        .clamp(1, MAX_CHARACTER_WIDTH);
327
328    // The schema is inferred from the features that will actually be written.
329    let owned: Vec<Feature> = kept
330        .iter()
331        .map(|feature| Feature {
332            geometry: feature.geometry.clone(),
333            properties: feature.properties.clone(),
334        })
335        .collect();
336    let schema = Schema::infer(&owned, max_field_length)?;
337
338    let (shp, shx) = write_geometry(&kept, family, dimension)?;
339    let dbf = write_attributes(&kept, &schema)?;
340
341    Ok(ShapefileParts {
342        shp,
343        shx,
344        dbf,
345        shape_type: format!("{}{}", family.label(), dimension.suffix()),
346        dimensions: dimension.label().to_string(),
347        feature_count: kept.len(),
348        skipped_count,
349        bbox: bounds(&kept),
350        fields: schema
351            .fields
352            .iter()
353            .map(|field| {
354                let (width, decimals) = match field.kind {
355                    FieldKind::Character { width } => (width as u32, 0),
356                    FieldKind::Numeric { width, decimals } => (width as u32, decimals as u32),
357                    FieldKind::Logical => (1, 0),
358                };
359                FieldReport {
360                    source: if schema.synthetic {
361                        field.name.clone()
362                    } else {
363                        field.source.clone()
364                    },
365                    name: field.name.clone(),
366                    kind: field.kind.label(),
367                    width,
368                    decimals,
369                }
370            })
371            .collect(),
372    })
373}
374
375fn resolve_family(features: &[&Feature], options: &Options) -> Result<Family> {
376    if let Some(requested) = &options.shape_type {
377        return Family::parse(requested)
378            .ok_or_else(|| ShapefileError::UnsupportedGeometry(requested.clone()));
379    }
380
381    let mut resolved: Option<Family> = None;
382    for (index, feature) in features.iter().enumerate() {
383        let Some(geometry) = &feature.geometry else {
384            continue;
385        };
386        let family = geometry.family();
387        resolved = Some(match resolved {
388            None => family,
389            Some(current) => current.reconcile(family, index)?,
390        });
391    }
392
393    resolved.ok_or(ShapefileError::Empty)
394}
395
396fn resolve_dimension(features: &[&Feature], options: &Options) -> Result<Dimension> {
397    if let Some(requested) = &options.dimensions {
398        if requested.eq_ignore_ascii_case("auto") {
399            // fall through to detection
400        } else {
401            return Dimension::parse(requested)
402                .ok_or_else(|| ShapefileError::Input(format!("unknown dimensions `{requested}`")));
403        }
404    }
405
406    let mut max_arity = 2;
407    for feature in features {
408        if let Some(geometry) = &feature.geometry {
409            geometry.for_each_position(|position| max_arity = max_arity.max(position.arity()));
410        }
411    }
412
413    Ok(Dimension::from_arity(max_arity))
414}
415
416fn bounds(features: &[&Feature]) -> Vec<f64> {
417    let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
418    let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
419
420    for feature in features {
421        if let Some(geometry) = &feature.geometry {
422            geometry.for_each_position(|position| {
423                min_x = min_x.min(position.x);
424                min_y = min_y.min(position.y);
425                max_x = max_x.max(position.x);
426                max_y = max_y.max(position.y);
427            });
428        }
429    }
430
431    if min_x.is_finite() {
432        vec![min_x, min_y, max_x, max_y]
433    } else {
434        vec![0.0, 0.0, 0.0, 0.0]
435    }
436}
437
438/// Writes every shape, choosing the concrete shapefile type from the resolved
439/// family and dimensionality.
440fn write_geometry(
441    features: &[&Feature],
442    family: Family,
443    dimension: Dimension,
444) -> Result<(Vec<u8>, Vec<u8>)> {
445    let mut shp = Cursor::new(Vec::new());
446    let mut shx = Cursor::new(Vec::new());
447
448    macro_rules! emit {
449        ($convert:ident, $point:ty) => {{
450            let mut shapes = Vec::with_capacity(features.len());
451            for (index, feature) in features.iter().enumerate() {
452                let geometry = feature
453                    .geometry
454                    .as_ref()
455                    .expect("features without geometry were filtered out");
456                shapes.push(geometry::$convert::<$point>(geometry, dimension, index)?);
457            }
458            flush(&shapes, &mut shp, &mut shx)?;
459        }};
460    }
461
462    match (family, dimension) {
463        (Family::Point, Dimension::Xy) => emit!(to_point, Point),
464        (Family::Point, Dimension::Xym) => emit!(to_point, PointM),
465        (Family::Point, Dimension::Xyz | Dimension::Xyzm) => emit!(to_point, PointZ),
466
467        (Family::Multipoint, Dimension::Xy) => emit!(to_multipoint, Point),
468        (Family::Multipoint, Dimension::Xym) => emit!(to_multipoint, PointM),
469        (Family::Multipoint, Dimension::Xyz | Dimension::Xyzm) => emit!(to_multipoint, PointZ),
470
471        (Family::Polyline, Dimension::Xy) => emit!(to_polyline, Point),
472        (Family::Polyline, Dimension::Xym) => emit!(to_polyline, PointM),
473        (Family::Polyline, Dimension::Xyz | Dimension::Xyzm) => emit!(to_polyline, PointZ),
474
475        (Family::Polygon, Dimension::Xy) => emit!(to_polygon, Point),
476        (Family::Polygon, Dimension::Xym) => emit!(to_polygon, PointM),
477        (Family::Polygon, Dimension::Xyz | Dimension::Xyzm) => emit!(to_polygon, PointZ),
478    }
479
480    Ok((shp.into_inner(), shx.into_inner()))
481}
482
483/// The header is only correct once `finalize` has rewound and rewritten it, so
484/// the writer has to be done with the cursors before we read them back.
485fn flush<S: EsriShape>(
486    shapes: &[S],
487    shp: &mut Cursor<Vec<u8>>,
488    shx: &mut Cursor<Vec<u8>>,
489) -> Result<()> {
490    let mut writer = ShapeWriter::with_shx(shp, shx);
491    for shape in shapes {
492        writer.write_shape(shape)?;
493    }
494    writer.finalize()?;
495    Ok(())
496}
497
498fn write_attributes(features: &[&Feature], schema: &Schema) -> Result<Vec<u8>> {
499    let mut dbf = Cursor::new(Vec::new());
500    {
501        let mut table = schema.builder()?.build_with_dest(&mut dbf);
502        for (index, feature) in features.iter().enumerate() {
503            table.write_record(&schema.record(&feature.properties, index))?;
504        }
505        table.finalize()?;
506    }
507    Ok(dbf.into_inner())
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use serde_json::json;
514
515    fn build_from(value: serde_json::Value) -> ShapefileParts {
516        build(&value, &Options::default()).expect("build should succeed")
517    }
518
519    #[test]
520    fn writes_a_point_shapefile_with_valid_headers() {
521        let parts = build_from(json!({
522            "type": "FeatureCollection",
523            "features": [
524                {
525                    "type": "Feature",
526                    "geometry": { "type": "Point", "coordinates": [-122.4194, 37.7749] },
527                    "properties": { "name": "San Francisco" }
528                },
529                {
530                    "type": "Feature",
531                    "geometry": { "type": "Point", "coordinates": [-74.006, 40.7128] },
532                    "properties": { "name": "New York" }
533                }
534            ]
535        }));
536
537        assert_eq!(parts.shape_type, "Point");
538        assert_eq!(parts.feature_count, 2);
539        // Shapefile magic number 9994, big endian.
540        assert_eq!(&parts.shp[0..4], &[0x00, 0x00, 0x27, 0x0a]);
541        assert_eq!(&parts.shx[0..4], &[0x00, 0x00, 0x27, 0x0a]);
542        // .shx holds a 100 byte header plus 8 bytes per record.
543        assert_eq!(parts.shx.len(), 100 + 2 * 8);
544        assert!(!parts.dbf.is_empty());
545    }
546
547    #[test]
548    fn detects_z_coordinates() {
549        let parts = build_from(json!({
550            "type": "Feature",
551            "geometry": { "type": "Point", "coordinates": [1.0, 2.0, 3.0] },
552            "properties": {}
553        }));
554        assert_eq!(parts.shape_type, "PointZ");
555    }
556
557    #[test]
558    fn detects_measures_on_four_ordinate_coordinates() {
559        let parts = build_from(json!({
560            "type": "Feature",
561            "geometry": { "type": "Point", "coordinates": [1.0, 2.0, 3.0, 4.0] },
562            "properties": {}
563        }));
564        assert_eq!(parts.shape_type, "PointZ");
565        assert_eq!(parts.bbox, vec![1.0, 2.0, 1.0, 2.0]);
566    }
567
568    #[test]
569    fn multipolygon_collapses_into_one_polygon_record() {
570        let parts = build_from(json!({
571            "type": "Feature",
572            "geometry": {
573                "type": "MultiPolygon",
574                "coordinates": [
575                    [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]],
576                    [[[5.0, 5.0], [6.0, 5.0], [6.0, 6.0], [5.0, 5.0]]]
577                ]
578            },
579            "properties": {}
580        }));
581        assert_eq!(parts.shape_type, "Polygon");
582        assert_eq!(parts.feature_count, 1);
583        assert_eq!(parts.bbox, vec![0.0, 0.0, 6.0, 6.0]);
584    }
585
586    #[test]
587    fn points_and_multipoints_are_promoted_to_multipoint() {
588        let parts = build_from(json!([
589            { "type": "Point", "coordinates": [0.0, 0.0] },
590            { "type": "MultiPoint", "coordinates": [[1.0, 1.0], [2.0, 2.0]] }
591        ]));
592        assert_eq!(parts.shape_type, "Multipoint");
593        assert_eq!(parts.feature_count, 2);
594    }
595
596    #[test]
597    fn mixing_lines_and_polygons_is_rejected() {
598        let error = build(
599            &json!([
600                { "type": "LineString", "coordinates": [[0.0, 0.0], [1.0, 1.0]] },
601                { "type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]] }
602            ]),
603            &Options::default(),
604        )
605        .unwrap_err();
606        assert!(matches!(error, ShapefileError::MixedGeometry { .. }));
607    }
608
609    #[test]
610    fn features_without_geometry_are_skipped_not_misaligned() {
611        let parts = build_from(json!({
612            "type": "FeatureCollection",
613            "features": [
614                { "type": "Feature", "geometry": null, "properties": { "name": "nowhere" } },
615                {
616                    "type": "Feature",
617                    "geometry": { "type": "Point", "coordinates": [1.0, 2.0] },
618                    "properties": { "name": "somewhere" }
619                }
620            ]
621        }));
622        assert_eq!(parts.feature_count, 1);
623        assert_eq!(parts.skipped_count, 1);
624        assert_eq!(parts.shx.len(), 100 + 8);
625    }
626
627    #[test]
628    fn ragged_properties_still_produce_one_row_per_feature() {
629        // dbase errors if a record omits a declared field, so every row has to be
630        // padded with nulls for the fields it does not carry.
631        let parts = build_from(json!({
632            "type": "FeatureCollection",
633            "features": [
634                {
635                    "type": "Feature",
636                    "geometry": { "type": "Point", "coordinates": [0.0, 0.0] },
637                    "properties": { "a": 1 }
638                },
639                {
640                    "type": "Feature",
641                    "geometry": { "type": "Point", "coordinates": [1.0, 1.0] },
642                    "properties": { "b": "two" }
643                }
644            ]
645        }));
646        assert_eq!(parts.fields.len(), 2);
647        assert_eq!(parts.feature_count, 2);
648    }
649
650    #[test]
651    fn long_property_names_are_truncated_and_reported() {
652        let parts = build_from(json!({
653            "type": "Feature",
654            "geometry": { "type": "Point", "coordinates": [0.0, 0.0] },
655            "properties": { "a_very_long_property_name": 1 }
656        }));
657        assert_eq!(parts.fields[0].source, "a_very_long_property_name");
658        assert_eq!(parts.fields[0].name, "a_very_long");
659    }
660
661    #[test]
662    fn attribute_less_input_still_gets_a_column() {
663        let parts = build_from(json!({ "type": "Point", "coordinates": [0.0, 0.0] }));
664        assert_eq!(parts.fields.len(), 1);
665        assert_eq!(parts.fields[0].name, "FID");
666    }
667
668    #[test]
669    fn short_rings_are_rejected_rather_than_panicking() {
670        let error = build(
671            &json!({
672                "type": "Polygon",
673                "coordinates": [[[0.0, 0.0], [1.0, 1.0]]]
674            }),
675            &Options::default(),
676        )
677        .unwrap_err();
678        assert!(matches!(error, ShapefileError::Feature { .. }));
679    }
680
681    /// Writes the value out and reads it straight back, which is the only real
682    /// check that the bytes we produce are the bytes a reader expects.
683    fn round_trip(value: serde_json::Value) -> serde_json::Value {
684        let parts = build_from(value);
685        read::read(&parts.shp, Some(&parts.dbf), &read::ReadOptions::default())
686            .expect("read should succeed")
687    }
688
689    #[test]
690    fn points_round_trip_with_attributes() {
691        let output = round_trip(json!({
692            "type": "FeatureCollection",
693            "features": [{
694                "type": "Feature",
695                "geometry": { "type": "Point", "coordinates": [-122.4194, 37.7749] },
696                "properties": { "name": "San Francisco", "pop": 873965, "capital": false }
697            }]
698        }));
699
700        let feature = &output["features"][0];
701        assert_eq!(feature["geometry"]["type"], "Point");
702        assert_eq!(feature["geometry"]["coordinates"][0], -122.4194);
703        assert_eq!(feature["properties"]["name"], "San Francisco");
704        assert_eq!(feature["properties"]["pop"], 873965.0);
705        assert_eq!(feature["properties"]["capital"], false);
706    }
707
708    #[test]
709    fn polygon_holes_survive_the_round_trip() {
710        // A shapefile stores rings flat, so the hole has to be re-nested on read.
711        let output = round_trip(json!({
712            "type": "Feature",
713            "geometry": {
714                "type": "Polygon",
715                "coordinates": [
716                    [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]],
717                    [[2.0, 2.0], [2.0, 4.0], [4.0, 4.0], [4.0, 2.0], [2.0, 2.0]]
718                ]
719            },
720            "properties": {}
721        }));
722
723        let geometry = &output["features"][0]["geometry"];
724        assert_eq!(geometry["type"], "Polygon");
725        let rings = geometry["coordinates"].as_array().unwrap();
726        assert_eq!(rings.len(), 2, "exterior plus one hole");
727    }
728
729    #[test]
730    fn holes_attach_to_the_ring_that_contains_them() {
731        // Two disjoint squares, each with its own hole. If the nesting logic just
732        // paired rings by order it would still pass, so the holes are given in
733        // reverse order relative to their exteriors.
734        let output = round_trip(json!({
735            "type": "Feature",
736            "geometry": {
737                "type": "MultiPolygon",
738                "coordinates": [
739                    [
740                        [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]],
741                        [[1.0, 1.0], [1.0, 2.0], [2.0, 2.0], [2.0, 1.0], [1.0, 1.0]]
742                    ],
743                    [
744                        [[100.0, 100.0], [110.0, 100.0], [110.0, 110.0], [100.0, 110.0], [100.0, 100.0]],
745                        [[101.0, 101.0], [101.0, 102.0], [102.0, 102.0], [102.0, 101.0], [101.0, 101.0]]
746                    ]
747                ]
748            },
749            "properties": {}
750        }));
751
752        let geometry = &output["features"][0]["geometry"];
753        assert_eq!(geometry["type"], "MultiPolygon");
754        let polygons = geometry["coordinates"].as_array().unwrap();
755        assert_eq!(polygons.len(), 2);
756
757        for polygon in polygons {
758            let rings = polygon.as_array().unwrap();
759            assert_eq!(rings.len(), 2, "each square keeps exactly its own hole");
760            // The hole must sit inside its own exterior, not the far-away one.
761            let exterior_x = rings[0][0][0].as_f64().unwrap();
762            let hole_x = rings[1][0][0].as_f64().unwrap();
763            assert!(
764                (exterior_x - hole_x).abs() < 50.0,
765                "hole at x={hole_x} was attached to the ring at x={exterior_x}"
766            );
767        }
768    }
769
770    #[test]
771    fn exterior_rings_come_back_counter_clockwise() {
772        // RFC 7946 wants exteriors CCW and holes CW; shapefiles store the
773        // opposite, so the reader has to rewind them.
774        let output = round_trip(json!({
775            "type": "Feature",
776            "geometry": {
777                "type": "Polygon",
778                "coordinates": [
779                    [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]],
780                    [[2.0, 2.0], [2.0, 4.0], [4.0, 4.0], [4.0, 2.0], [2.0, 2.0]]
781                ]
782            },
783            "properties": {}
784        }));
785
786        let rings = output["features"][0]["geometry"]["coordinates"]
787            .as_array()
788            .unwrap();
789        assert!(signed_area(&rings[0]) > 0.0, "exterior should be CCW");
790        assert!(signed_area(&rings[1]) < 0.0, "hole should be CW");
791    }
792
793    fn signed_area(ring: &serde_json::Value) -> f64 {
794        let points: Vec<(f64, f64)> = ring
795            .as_array()
796            .unwrap()
797            .iter()
798            .map(|p| (p[0].as_f64().unwrap(), p[1].as_f64().unwrap()))
799            .collect();
800        let mut total = 0.0;
801        for window in points.windows(2) {
802            total += (window[1].0 - window[0].0) * (window[1].1 + window[0].1);
803        }
804        -total
805    }
806
807    #[test]
808    fn multilinestring_round_trips_as_multilinestring() {
809        let output = round_trip(json!({
810            "type": "Feature",
811            "geometry": {
812                "type": "MultiLineString",
813                "coordinates": [
814                    [[0.0, 0.0], [1.0, 1.0]],
815                    [[5.0, 5.0], [6.0, 6.0]]
816                ]
817            },
818            "properties": {}
819        }));
820        assert_eq!(output["features"][0]["geometry"]["type"], "MultiLineString");
821    }
822
823    #[test]
824    fn single_part_lines_come_back_as_linestring() {
825        let output = round_trip(json!({
826            "type": "Feature",
827            "geometry": { "type": "LineString", "coordinates": [[0.0, 0.0], [1.0, 1.0]] },
828            "properties": {}
829        }));
830        assert_eq!(output["features"][0]["geometry"]["type"], "LineString");
831    }
832
833    #[test]
834    fn z_coordinates_survive_the_round_trip() {
835        let output = round_trip(json!({
836            "type": "Feature",
837            "geometry": { "type": "Point", "coordinates": [1.0, 2.0, 3.5] },
838            "properties": {}
839        }));
840        let coordinates = &output["features"][0]["geometry"]["coordinates"];
841        assert_eq!(coordinates.as_array().unwrap().len(), 3);
842        assert_eq!(coordinates[2], 3.5);
843    }
844
845    #[test]
846    fn measures_are_dropped_unless_asked_for() {
847        let parts = build_from(json!({
848            "type": "Feature",
849            "geometry": { "type": "Point", "coordinates": [1.0, 2.0, 3.0, 4.0] },
850            "properties": {}
851        }));
852
853        let without =
854            read::read(&parts.shp, Some(&parts.dbf), &read::ReadOptions::default()).unwrap();
855        assert_eq!(
856            without["features"][0]["geometry"]["coordinates"]
857                .as_array()
858                .unwrap()
859                .len(),
860            3,
861            "GeoJSON has no measures, so M is dropped by default"
862        );
863
864        let with = read::read(
865            &parts.shp,
866            Some(&parts.dbf),
867            &read::ReadOptions {
868                include_m: true,
869                ..Default::default()
870            },
871        )
872        .unwrap();
873        let coordinates = &with["features"][0]["geometry"]["coordinates"];
874        assert_eq!(coordinates.as_array().unwrap().len(), 4);
875        assert_eq!(coordinates[3], 4.0);
876    }
877
878    #[test]
879    fn character_padding_is_trimmed_on_read() {
880        // dBase pads character fields to their full width; without trimming every
881        // string would come back with a tail of spaces.
882        let output = round_trip(json!({
883            "type": "FeatureCollection",
884            "features": [
885                {
886                    "type": "Feature",
887                    "geometry": { "type": "Point", "coordinates": [0.0, 0.0] },
888                    "properties": { "name": "ab" }
889                },
890                {
891                    "type": "Feature",
892                    "geometry": { "type": "Point", "coordinates": [1.0, 1.0] },
893                    "properties": { "name": "a much longer value" }
894                }
895            ]
896        }));
897        assert_eq!(output["features"][0]["properties"]["name"], "ab");
898    }
899
900    #[test]
901    fn property_order_is_preserved_through_the_round_trip() {
902        let output = round_trip(json!({
903            "type": "Feature",
904            "geometry": { "type": "Point", "coordinates": [0.0, 0.0] },
905            "properties": { "zebra": 1, "apple": 2, "mango": 3 }
906        }));
907        let properties = output["features"][0]["properties"].as_object().unwrap();
908        let order: Vec<&String> = properties.keys().collect();
909        assert_eq!(order, vec!["zebra", "apple", "mango"]);
910    }
911
912    #[test]
913    fn reading_without_a_dbf_yields_geometry_only() {
914        let parts = build_from(json!({
915            "type": "Feature",
916            "geometry": { "type": "Point", "coordinates": [1.0, 2.0] },
917            "properties": { "name": "x" }
918        }));
919        let output = read::read(&parts.shp, None, &read::ReadOptions::default()).unwrap();
920        assert_eq!(output["features"][0]["geometry"]["type"], "Point");
921        assert_eq!(
922            output["features"][0]["properties"]
923                .as_object()
924                .unwrap()
925                .len(),
926            0
927        );
928    }
929
930    #[test]
931    fn empty_input_is_an_error_not_a_corrupt_file() {
932        let error = build(
933            &json!({ "type": "FeatureCollection", "features": [] }),
934            &Options::default(),
935        )
936        .unwrap_err();
937        assert!(matches!(error, ShapefileError::Empty));
938    }
939}