1#![forbid(unsafe_code)]
5
6use std::io::Cursor;
21
22use serde::{Deserialize, Serialize};
23use wasm_bindgen::prelude::*;
24
25pub 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#[derive(Debug, Default, Deserialize)]
42#[serde(default, rename_all = "camelCase")]
43pub struct Options {
44 pub shape_type: Option<String>,
47 pub dimensions: Option<String>,
50 pub max_field_length: Option<usize>,
52}
53
54#[derive(Debug, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct FieldReport {
58 pub source: String,
60 pub name: String,
62 #[serde(rename = "type")]
64 pub kind: &'static str,
65 pub width: u32,
67 pub decimals: u32,
69}
70
71#[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 #[wasm_bindgen(getter)]
93 pub fn shp(&self) -> Vec<u8> {
94 self.shp.clone()
95 }
96
97 #[wasm_bindgen(getter)]
100 pub fn shx(&self) -> Vec<u8> {
101 self.shx.clone()
102 }
103
104 #[wasm_bindgen(getter)]
106 pub fn dbf(&self) -> Vec<u8> {
107 self.dbf.clone()
108 }
109
110 #[wasm_bindgen(getter)]
112 pub fn cpg(&self) -> String {
113 "UTF-8".to_string()
114 }
115
116 #[wasm_bindgen(getter, js_name = shapeType)]
118 pub fn shape_type(&self) -> String {
119 self.shape_type.clone()
120 }
121
122 #[wasm_bindgen(getter)]
124 pub fn dimensions(&self) -> String {
125 self.dimensions.clone()
126 }
127
128 #[wasm_bindgen(getter, js_name = featureCount)]
130 pub fn feature_count(&self) -> usize {
131 self.feature_count
132 }
133
134 #[wasm_bindgen(getter, js_name = skippedCount)]
136 pub fn skipped_count(&self) -> usize {
137 self.skipped_count
138 }
139
140 #[wasm_bindgen(getter)]
142 pub fn bbox(&self) -> Vec<f64> {
143 self.bbox.clone()
144 }
145
146 #[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#[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#[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#[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 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#[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#[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
302fn build(value: &serde_json::Value, options: &Options) -> Result<ShapefileParts> {
304 let features = input::normalize(value)?;
305 let total = features.len();
306
307 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 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 } 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
438fn 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
483fn 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 assert_eq!(&parts.shp[0..4], &[0x00, 0x00, 0x27, 0x0a]);
541 assert_eq!(&parts.shx[0..4], &[0x00, 0x00, 0x27, 0x0a]);
542 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 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 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 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 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 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 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 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}