Merge pull request #1174 from dylanmtaylor/codex/vector-file-imports

Add shared vector path import APIs
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 07:35:46 -05:00
commit 59c52ac1dc
12 files changed
+1144 -9

No files matched your search

Generated
+363 -9
View File
@@ -1394,6 +1394,7 @@ version = "0.1.0"
dependencies = [
"craft-codepage",
"encoding_rs",
"kurbo 0.13.1",
"log",
"thiserror 2.0.21",
]
@@ -1410,6 +1411,12 @@ dependencies = [
[[package]]
name = "craft-eps"
version = "0.1.0"
dependencies = [
"kurbo 0.13.1",
"thiserror 2.0.21",
"vectorcraft-doc",
"vectorcraft-eps",
]
[[package]]
name = "craft-epub"
@@ -1971,6 +1978,17 @@ dependencies = [
"zeroize",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"hayro-interpret",
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-pdn"
version = "0.1.0"
@@ -2027,7 +2045,7 @@ name = "craft-plate-test"
version = "0.1.0"
dependencies = [
"craft-cms",
"craft-pdf",
"craft-pdf 0.1.0",
"hayro-syntax",
"serde",
"serde_json",
@@ -2088,7 +2106,7 @@ name = "craft-prepress"
version = "0.1.0"
dependencies = [
"craft-cms",
"craft-pdf",
"craft-pdf 0.1.0",
"craft-plate-test",
"kurbo 0.13.1",
"linesweeper",
@@ -3497,7 +3515,7 @@ dependencies = [
"emath",
"epaint_default_fonts",
"font-types 0.12.7",
"harfrust",
"harfrust 0.12.0",
"log",
"nohash-hasher",
"parking_lot",
@@ -3725,6 +3743,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -4023,7 +4047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -4040,7 +4064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -4215,6 +4239,28 @@ dependencies = [
"smallvec",
]
[[package]]
name = "harfrust"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"once_cell",
"read-fonts 0.43.3",
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -4223,7 +4269,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -4232,7 +4278,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -6145,6 +6191,15 @@ dependencies = [
"libredox",
]
[[package]]
name = "ordered-channel"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab"
dependencies = [
"crossbeam-channel",
]
[[package]]
name = "ordered-float"
version = "5.5.0"
@@ -7095,6 +7150,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "read-fonts"
version = "0.43.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
dependencies = [
"bytemuck",
"font-types 0.12.7",
"once_cell",
]
[[package]]
name = "read-fonts"
version = "0.44.0"
@@ -7113,7 +7179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"font-types 0.12.7",
"once_cell",
"yoke",
@@ -8263,6 +8329,16 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -8706,6 +8782,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.11.3"
@@ -9231,7 +9316,7 @@ dependencies = [
"base64 0.23.1",
"data-url",
"fontdb",
"harfrust",
"harfrust 0.12.0",
"imagesize 0.15.0",
"kurbo 0.13.1",
"log",
@@ -9278,6 +9363,221 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vectorcraft-brush"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"kurbo 0.13.1",
"serde",
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-color"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"kurbo 0.13.1",
"moxcms",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "vectorcraft-doc"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"chrono",
"hayro-ccitt",
"image",
"kurbo 0.13.1",
"serde",
"serde_json",
"thiserror 2.0.21",
"tiff",
"vectorcraft-color",
"vectorcraft-geom",
"zune-jpeg",
]
[[package]]
name = "vectorcraft-effects"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"image",
"kurbo 0.13.1",
"serde_json",
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-plugins",
"vectorcraft-text",
"vectorcraft-three-d",
]
[[package]]
name = "vectorcraft-eps"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"flate2",
"hayro-ccitt",
"image",
"kurbo 0.13.1",
"memchr",
"ruzstd",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-geom"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"kurbo 0.13.1",
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "vectorcraft-metafile"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"image",
"kurbo 0.13.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-pathops"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
"thiserror 2.0.21",
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-pdf"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"aes 0.8.4",
"chrono",
"craft-pdf 0.1.0 (git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0)",
"flate2",
"getrandom 0.4.3",
"image",
"jpeg-encoder 0.6.1",
"kurbo 0.13.1",
"log",
"md-5 0.10.6",
"roxmltree 0.21.2",
"serde",
"sha2 0.10.9",
"skrifa 0.47.0",
"smallvec",
"thiserror 2.0.21",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-text",
"weezl 0.2.1",
]
[[package]]
name = "vectorcraft-plugins"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-geom",
"wasmi",
]
[[package]]
name = "vectorcraft-render"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"flate2",
"gif",
"image",
"jpeg-encoder 0.6.1",
"log",
"png 0.18.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-text",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
"weezl 0.2.1",
"winsafe",
]
[[package]]
name = "vectorcraft-text"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
"kurbo 0.13.1",
"log",
"skrifa 0.47.0",
"subsetter",
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"unicode-segmentation",
"vectorcraft-doc",
"vectorcraft-geom",
"vello_cpu 0.2.0",
"windows-registry",
]
[[package]]
name = "vectorcraft-three-d"
version = "0.18.0"
source = "git+https://github.com/storytold/vectorcraft.git?rev=6b3a6b8788ebcb420d69449199cbdf323888d27e#6b3a6b8788ebcb420d69449199cbdf323888d27e"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
]
[[package]]
name = "vello_common"
version = "0.0.8"
@@ -9348,9 +9648,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"crossbeam-channel",
"glifo 0.3.0",
"hashbrown 0.17.1",
"ordered-channel",
"png 0.18.1",
"rayon",
"thread_local",
"vello_common 0.2.0",
]
@@ -9508,6 +9812,56 @@ version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "wasmi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser",
]
[[package]]
name = "wasmi_collections"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.13.2",
"indexmap",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
+2
View File
@@ -44,6 +44,8 @@ craft-dxf = { path = "crates/dxf" }
craft-drawingml = { path = "crates/drawingml" }
craft-eps = { path = "crates/eps" }
craft-file-formats = { path = "crates/file-formats" }
vectorcraft-doc = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" }
vectorcraft-eps = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" }
craft-doc = { path = "crates/doc" }
craft-docpath = { path = "crates/docpath" }
craft-dpx = { path = "crates/dpx" }
+1
View File
@@ -15,3 +15,4 @@ craft-codepage = { workspace = true }
thiserror = { workspace = true }
log = "0.4"
encoding_rs = "0.8.35"
kurbo = { workspace = true }
+1
View File
@@ -11,6 +11,7 @@
use std::io::Write as _;
pub mod model;
pub mod paths;
mod version;
pub use version::Version;
+321
View File
@@ -0,0 +1,321 @@
//! Bounded conversion of common planar DXF entities into neutral Bézier paths.
//!
//! This is geometry extraction, not a full CAD scene renderer: unsupported entities such as
//! text, dimensions, meshes, and block inserts are reported as warnings and omitted.
use kurbo::{BezPath, Point, Rect, Shape as _};
/// Limits on the number of paths returned by [`import`]. Parsing also uses the crate's tag cap.
pub const MAX_PATHS: usize = 200_000;
/// Aggregate number of Bézier elements a drawing may generate during conversion.
pub const MAX_PATH_ELEMENTS: usize = 2_000_000;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("DXF parse failed: {0}")]
Parse(#[from] crate::DxfError),
#[error("DXF contains more than {MAX_PATHS} paths")]
TooManyPaths,
#[error("DXF curve exceeds the subdivision limit")]
TooManySegments,
#[error("DXF contains a non-finite path coordinate")]
NonFinite,
}
/// Geometry in source DXF coordinates. `unit_to_mm` maps a source coordinate to millimetres;
/// unitless drawings use `1.0`, as the coordinates are then interpreted as millimetres.
#[derive(Debug, Clone)]
pub struct Imported {
pub paths: Vec<BezPath>,
pub bounds: Option<Rect>,
pub unit_to_mm: f64,
pub warnings: Vec<String>,
}
/// Parse bounded DXF input and extract visible planar linework.
pub fn import(bytes: &[u8]) -> Result<Imported, Error> {
let tags = crate::parse_with_limit(bytes, crate::max_tags())?;
let drawing = crate::model::Document::read(&tags);
let unit_to_mm = units_to_mm(drawing.header.insunits(), drawing.header.measurement());
let layer_visibility: std::collections::HashMap<_, _> =
drawing.layers().into_iter().map(|layer| (layer.name, layer.on && !layer.frozen)).collect();
let mut paths = Vec::new();
let mut warnings = Vec::new();
let mut unsupported = 0usize;
let mut elements = 0usize;
for record in &drawing.entities {
let entity = crate::model::Entity::read(record);
if !entity.common.visible || record.paper_space() || layer_visibility.get(&entity.common.layer) == Some(&false) {
continue;
}
if excessive_sweep(&entity.kind) {
return Err(Error::TooManySegments);
}
let remaining = MAX_PATH_ELEMENTS.saturating_sub(elements);
if estimated_elements(&entity.kind) > remaining {
return Err(Error::TooManySegments);
}
let converted = entity_paths(&entity.kind, entity.common.extrusion);
if converted.iter().flat_map(|path| path.elements()).any(|element| {
let finite = |p: Point| p.x.is_finite() && p.y.is_finite();
match *element {
kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => !finite(p),
kurbo::PathEl::QuadTo(a, b) => !finite(a) || !finite(b),
kurbo::PathEl::CurveTo(a, b, c) => !finite(a) || !finite(b) || !finite(c),
kurbo::PathEl::ClosePath => false,
}
}) {
return Err(Error::NonFinite);
}
if converted.is_empty() {
unsupported = unsupported.saturating_add(1);
continue;
}
elements = elements.saturating_add(converted.iter().map(|path| path.elements().len()).sum::<usize>());
if elements > MAX_PATH_ELEMENTS {
return Err(Error::TooManySegments);
}
if paths.len().saturating_add(converted.len()) > MAX_PATHS {
return Err(Error::TooManyPaths);
}
paths.extend(converted);
}
if unsupported > 0 {
warnings.push(format!("{unsupported} visible DXF entities are unsupported and were omitted"));
}
if drawing.skipped.iter().any(|(_, n)| *n > 0) {
warnings.push("some unsupported or stray DXF records were skipped".into());
}
let bounds = paths.iter().map(|path| path.bounding_box()).reduce(|a, b| a.union(b));
Ok(Imported { paths, bounds, unit_to_mm, warnings })
}
fn estimated_elements(kind: &crate::model::EntityKind) -> usize {
use crate::model::EntityKind as E;
let arc_count = |sweep: f64| (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize;
let bulge_count = |bulge: f64| {
if bulge.is_finite() && bulge.abs() > 1e-12 { arc_count(4.0 * bulge.atan()) } else { 1 }
};
let polyline_count = |vertices: &[(Point, f64)], closed: bool| {
if vertices.is_empty() {
return 0;
}
let mut count = 1usize;
for pair in vertices.windows(2) {
count = count.saturating_add(bulge_count(pair[0].1));
}
if closed && vertices.len() > 1 {
count = count.saturating_add(bulge_count(vertices.last().map_or(0.0, |vertex| vertex.1)));
count = count.saturating_add(1);
}
count
};
match kind {
E::Line { .. } => 2,
E::Circle { .. } => 6,
E::Arc { start_angle, end_angle, .. } => 1usize.saturating_add(arc_count((end_angle - start_angle).to_radians())),
E::LwPolyline(poly) => {
let vertices: Vec<_> = poly.vertices.iter().map(|v| (Point::new(v.x, v.y), v.bulge)).collect();
polyline_count(&vertices, poly.closed)
}
E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => {
let vertices: Vec<_> = poly
.vertices
.iter()
.filter(|vertex| !vertex.is_face())
.map(|vertex| (Point::new(vertex.location[0], vertex.location[1]), vertex.bulge))
.collect();
polyline_count(&vertices, poly.closed())
}
E::Ellipse { start, end, .. } => 1usize.saturating_add(arc_count(end - start)),
_ => 0,
}
}
fn units_to_mm(code: i64, _measurement: Option<i64>) -> f64 {
match code {
0 => 1.0,
1 => 25.4,
2 => 304.8,
3 => 1_609_344.0,
5 => 10.0,
6 => 1_000.0,
7 => 1_000_000.0,
8 => 0.000_025_4,
9 => 0.0254,
10 => 914.4,
11 => 1e-7,
12 => 1e-6,
13 => 0.001,
14 => 100.0,
15 => 10_000.0,
16 => 100_000.0,
17 => 1e12,
18 => 149_597_870_700_000.0,
19 => 9.460_730_472_580_8e18,
20 => 3.085_677_581_491_367e19,
21 => 1200.0 / 3937.0 * 1000.0,
22 => 1200.0 / 3937.0 * 1000.0 / 12.0,
23 => 1200.0 / 3937.0 * 1000.0 * 3.0,
24 => 1200.0 / 3937.0 * 1000.0 * 5280.0,
_ => 1.0,
}
}
fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> Vec<BezPath> {
use crate::model::EntityKind as E;
match kind {
E::Line { start, end } => vec![line(*start, *end)],
E::Circle { center, radius } if radius.is_finite() && *radius > 0.0 => {
vec![map_ocs_path(circle(Point::new(center[0], center[1]), *radius), normal, center[2])]
}
E::Arc { center, radius, start_angle, end_angle }
if radius.is_finite() && *radius > 0.0 && start_angle.is_finite() && end_angle.is_finite() =>
{
vec![map_ocs_path(arc(Point::new(center[0], center[1]), *radius, start_angle.to_radians(), end_angle.to_radians()), normal, center[2])]
}
E::LwPolyline(poly) => {
vec![map_ocs_path(polyline(poly.vertices.iter().map(|v| (Point::new(v.x, v.y), v.bulge)), poly.closed), normal, poly.elevation)]
}
E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => {
let is_3d = poly.flags & 8 != 0;
let z = if is_3d { 0.0 } else { poly.elevation };
let path =
polyline(poly.vertices.iter().filter(|v| !v.is_face()).map(|v| (Point::new(v.location[0], v.location[1]), v.bulge)), poly.closed());
vec![if is_3d { path } else { map_ocs_path(path, normal, z) }]
}
E::Ellipse { center, major, ratio, start, end } if ratio.is_finite() && *ratio > 0.0 => {
vec![ellipse(*center, *major, normal, *ratio, *start, *end)]
}
_ => Vec::new(),
}
}
fn map_ocs_path(path: BezPath, normal: crate::model::Point, z: f64) -> BezPath {
let [x, y, n] = crate::model::ocs_axes(normal);
let transform = kurbo::Affine::new([x[0], x[1], y[0], y[1], n[0] * z, n[1] * z]);
transform * path
}
fn excessive_sweep(kind: &crate::model::EntityKind) -> bool {
let cap = 4096.0 * std::f64::consts::FRAC_PI_2;
let over = |sweep: f64| !sweep.is_finite() || sweep.abs() > cap;
match kind {
crate::model::EntityKind::Arc { start_angle, end_angle, .. } => over((end_angle - start_angle).to_radians()),
crate::model::EntityKind::Ellipse { start, end, .. } => over(end - start),
_ => false,
}
}
fn line(start: crate::model::Point, end: crate::model::Point) -> BezPath {
let mut path = BezPath::new();
path.move_to((start[0], start[1]));
path.line_to((end[0], end[1]));
path
}
fn polyline(vertices: impl IntoIterator<Item = (Point, f64)>, closed: bool) -> BezPath {
let vertices = vertices.into_iter().collect::<Vec<_>>();
let mut path = BezPath::new();
let Some((first, _)) = vertices.first().copied() else { return path };
path.move_to(first);
for i in 1..vertices.len() {
let (a, bulge) = vertices[i - 1];
let (b, _) = vertices[i];
if bulge.is_finite() && bulge.abs() > 1e-12 {
append_bulge(&mut path, a, b, bulge);
} else {
path.line_to(b);
}
}
if closed && vertices.len() > 1 {
let (a, bulge) = *vertices.last().unwrap_or(&(first, 0.0));
if bulge.is_finite() && bulge.abs() > 1e-12 {
append_bulge(&mut path, a, first, bulge);
} else {
path.line_to(first);
}
path.close_path();
}
path
}
fn append_bulge(path: &mut BezPath, a: Point, b: Point, bulge: f64) {
let chord = b - a;
let length = chord.hypot();
if !(length.is_finite() && length > 0.0) {
path.line_to(b);
return;
}
let center = a + chord * 0.5 + kurbo::Vec2::new(-chord.y, chord.x) * ((1.0 - bulge * bulge) / (4.0 * bulge));
let start = (a.y - center.y).atan2(a.x - center.x);
let sweep = 4.0 * bulge.atan();
let radius = length * (1.0 + bulge * bulge) / (4.0 * bulge.abs());
arc_segments(path, center, start, sweep, radius);
}
fn circle(center: Point, radius: f64) -> BezPath {
let mut path = BezPath::new();
path.move_to((center.x + radius, center.y));
arc_segments(&mut path, center, 0.0, std::f64::consts::TAU, radius);
path.close_path();
path
}
fn arc(center: Point, radius: f64, start: f64, end: f64) -> BezPath {
let mut path = BezPath::new();
path.move_to((center.x + radius * start.cos(), center.y + radius * start.sin()));
let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU };
arc_segments(&mut path, center, start, sweep, radius);
path
}
fn arc_segments(path: &mut BezPath, center: Point, start: f64, sweep: f64, radius: f64) {
let count = (sweep.abs() / (std::f64::consts::FRAC_PI_2)).ceil().max(1.0) as usize;
if count > 4096 {
return;
}
let step = sweep / count as f64;
for i in 0..count {
let a = start + step * i as f64;
let b = a + step;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let p1 = Point::new(center.x + radius * (a.cos() - k * a.sin()), center.y + radius * (a.sin() + k * a.cos()));
let p2 = Point::new(center.x + radius * (b.cos() + k * b.sin()), center.y + radius * (b.sin() - k * b.cos()));
let p3 = Point::new(center.x + radius * b.cos(), center.y + radius * b.sin());
path.curve_to(p1, p2, p3);
}
}
fn ellipse(center: crate::model::Point, major: crate::model::Point, normal: crate::model::Point, ratio: f64, start: f64, end: f64) -> BezPath {
let minor = cross(normal, major);
let minor_len = minor.iter().map(|v| v * v).sum::<f64>().sqrt();
if !(minor_len.is_finite() && minor_len > 0.0) {
return BezPath::new();
}
let major_len = major.iter().map(|v| v * v).sum::<f64>().sqrt();
let bx = [minor[0] / minor_len * major_len * ratio, minor[1] / minor_len * major_len * ratio, minor[2] / minor_len * major_len * ratio];
let point = |t: f64| Point::new(center[0] + major[0] * t.cos() + bx[0] * t.sin(), center[1] + major[1] * t.cos() + bx[1] * t.sin());
let mut path = BezPath::new();
path.move_to(point(start));
let sweep = if end >= start { end - start } else { end - start + std::f64::consts::TAU };
let count = (sweep.abs() / std::f64::consts::FRAC_PI_2).ceil().max(1.0) as usize;
if count > 4096 {
return path;
}
let step = sweep / count as f64;
for i in 0..count {
let a = start + step * i as f64;
let b = a + step;
let k = 4.0 / 3.0 * (step / 4.0).tan();
let derivative = |t: f64| Point::new(-major[0] * t.sin() + bx[0] * t.cos(), -major[1] * t.sin() + bx[1] * t.cos());
let p1 = point(a) + derivative(a).to_vec2() * k;
let p2 = point(b) - derivative(b).to_vec2() * k;
path.curve_to(p1, p2, point(b));
}
path
}
fn cross(a: crate::model::Point, b: crate::model::Point) -> crate::model::Point {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
+8
View File
@@ -11,3 +11,11 @@ repository.workspace = true
workspace = true
[dependencies]
kurbo = { workspace = true, optional = true }
vectorcraft-doc = { workspace = true, optional = true }
vectorcraft-eps = { workspace = true, optional = true }
thiserror = { workspace = true, optional = true }
[features]
default = []
paths = ["dep:kurbo", "dep:thiserror", "dep:vectorcraft-doc", "dep:vectorcraft-eps"]
+2
View File
@@ -18,6 +18,8 @@
pub mod ascii85;
mod container;
mod dsc;
#[cfg(feature = "paths")]
pub mod paths;
mod preview;
mod rectangles;
+103
View File
@@ -0,0 +1,103 @@
//! Neutral path extraction for EPS and PostScript files.
//!
//! This facade keeps the PostScript interpreter and its format decisions in shared dependencies;
//! applications receive Bézier paths, page bounds and warnings for their own document adapters.
use kurbo::{BezPath, Rect};
/// Limits on the extracted geometry. The interpreter has its own execution and allocation caps.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_input_bytes: usize,
pub max_paths: usize,
pub max_path_elements: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_input_bytes: 64 * 1024 * 1024, max_paths: 200_000, max_path_elements: 2_000_000 }
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("PostScript input exceeds {limit} bytes")]
InputLimit { limit: usize },
#[error("PostScript import failed: {0}")]
Parse(String),
#[error("PostScript path output exceeds configured limit")]
OutputLimit,
}
/// First-page path geometry normalized to the top-left origin in PostScript points, plus notes.
#[derive(Debug, Clone)]
pub struct Imported {
pub paths: Vec<BezPath>,
/// Origin-zero page bounds in PostScript points, using the same accepted DSC box as the
/// interpreter.
pub page_bounds: Rect,
pub warnings: Vec<String>,
pub preview_fallback: bool,
}
/// Interpret EPS/PostScript bytes and return their editable vector path geometry.
pub fn import(bytes: &[u8], limits: Limits) -> Result<Imported, Error> {
if bytes.len() > limits.max_input_bytes {
return Err(Error::InputLimit { limit: limits.max_input_bytes });
}
let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?;
let page_bounds = vectorcraft_eps::page(bytes).map_err(Error::Parse)?;
let mut paths = Vec::new();
let mut total_elements = 0usize;
let mut clipped = false;
let mut omitted = false;
for layer in &source.document.layers {
collect(layer, &mut paths, &mut total_elements, limits, &mut clipped, &mut omitted)?;
}
let mut warnings = source.warnings;
if clipped {
warnings.push("EPS clipping groups are returned as unclipped artwork".into());
}
if omitted {
warnings.push("visible EPS text or image content is not returned as paths".into());
}
Ok(Imported { paths, page_bounds, warnings, preview_fallback: source.preview })
}
fn collect(
node: &vectorcraft_doc::Node,
paths: &mut Vec<BezPath>,
elements: &mut usize,
limits: Limits,
clipped: &mut bool,
omitted: &mut bool,
) -> Result<(), Error> {
if !node.visible {
return Ok(());
}
if let Some(data) = node.path_data() {
if matches!(node.kind, vectorcraft_doc::NodeKind::Path { clipping: true, .. }) {
return Ok(());
}
let path = data.to_bezpath();
if !path.elements().is_empty() {
*elements = elements.saturating_add(path.elements().len());
if paths.len() >= limits.max_paths || *elements > limits.max_path_elements {
return Err(Error::OutputLimit);
}
paths.push(path);
}
} else if let Some(children) = node.children() {
let is_clip = matches!(node.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. } | vectorcraft_doc::NodeKind::Layer { clip: true, .. });
*clipped |= is_clip;
for (index, child) in children.iter().enumerate() {
if is_clip && index == 0 {
continue;
}
collect(child, paths, elements, limits, clipped, omitted)?;
}
} else {
*omitted = true;
}
Ok(())
}
+2
View File
@@ -14,6 +14,7 @@ workspace = true
default = []
read = ["dep:hayro-syntax", "dep:thiserror"]
interpret = ["read", "dep:hayro-interpret"]
paths = ["interpret", "dep:kurbo"]
raster = ["interpret", "dep:hayro", "dep:bytemuck"]
write = ["dep:krilla", "dep:thiserror"]
write-pdf = ["write", "krilla/pdf"]
@@ -40,6 +41,7 @@ stringprep = { version = "0.1", optional = true }
zeroize = { version = "1", optional = true }
# PDF date strings in the object layer (`cos`).
chrono = { workspace = true, optional = true }
kurbo = { workspace = true, optional = true }
# On the web, a rewrite's fresh file identifier (`cos`) takes its entropy from the browser
# (`crypto.getRandomValues`); native builds compile neither.
+2
View File
@@ -21,6 +21,8 @@ pub use link::safe_url;
mod read;
#[cfg(feature = "read")]
pub use read::*;
#[cfg(feature = "paths")]
pub mod paths;
#[cfg(feature = "raster")]
pub mod raster;
#[cfg(feature = "write")]
+184
View File
@@ -0,0 +1,184 @@
//! Bounded extraction of page vector paths from PDF content streams.
//!
//! Paths are returned in page-oriented points with `/UserUnit`, rotation and crop origin applied.
//! Text glyphs and raster images are counted as omitted content; clipping and paint are not applied.
use hayro_interpret::font::Glyph;
use hayro_interpret::{
BlendMode, ClipPath, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, TransformExt,
};
use kurbo::{Affine, BezPath, Rect};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
/// Limits for vector path extraction. They bound the output model, not interpreter peak memory.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_input_bytes: usize,
pub max_pages: usize,
pub max_paths: usize,
pub max_path_elements: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_input_bytes: 64 * 1024 * 1024, max_pages: 1000, max_paths: 200_000, max_path_elements: 2_000_000 }
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Open(#[from] crate::read::OpenError),
#[error(transparent)]
Interpret(#[from] crate::read::ExecutionError),
#[error("PDF contains more than {limit} pages")]
Pages { limit: usize },
#[error("PDF has no page at index {index}")]
PageIndex { index: usize },
#[error("PDF path output exceeds configured limit")]
OutputLimit,
}
/// Extracted paths for one PDF page, normalized to an origin-zero page in physical points.
#[derive(Debug, Clone)]
pub struct Page {
pub crop_box: Rect,
pub paths: Vec<BezPath>,
pub omitted_text: usize,
pub omitted_images: usize,
pub clips_seen: usize,
}
/// Pages from one PDF. Paths from separate pages are never combined.
#[derive(Debug, Clone)]
pub struct Imported {
pub pages: Vec<Page>,
pub warnings: Vec<String>,
}
/// Open and interpret vector content using shared PDF parsing and interpreter APIs.
pub fn import(bytes: &[u8], limits: Limits) -> Result<Imported, Error> {
import_selected(bytes, limits, None)
}
/// Interpret only the requested page and apply path and element limits to that page's output.
pub fn import_page(bytes: &[u8], page_index: usize, limits: Limits) -> Result<Imported, Error> {
import_selected(bytes, limits, Some(page_index))
}
fn import_selected(bytes: &[u8], limits: Limits, selected: Option<usize>) -> Result<Imported, Error> {
let options = crate::read::OpenOptions { max_input_bytes: limits.max_input_bytes, ..Default::default() };
let pdf = crate::read::open_slice_with_options(bytes, None, &options)?;
if pdf.pages().len() > limits.max_pages {
return Err(Error::Pages { limit: limits.max_pages });
}
if selected.is_some_and(|index| index >= pdf.pages().len()) {
return Err(Error::PageIndex { index: selected.unwrap_or_default() });
}
let cache = InterpreterCache::new();
let truncated = Arc::new(AtomicBool::new(false));
let truncation_flag = truncated.clone();
let settings = InterpreterSettings {
warning_sink: Arc::new(move |warning| {
if matches!(warning, hayro_interpret::InterpreterWarning::ContentTruncated) {
truncation_flag.store(true, Ordering::Relaxed);
}
}),
..InterpreterSettings::default()
};
let mut pages = Vec::with_capacity(pdf.pages().len());
let mut total_paths = 0usize;
let mut total_elements = 0usize;
for (page_index, page) in pdf.pages().iter().enumerate() {
if selected.is_some_and(|index| index != page_index) {
continue;
}
let source_crop_box = page.intersected_crop_box().to_kurbo();
let (width, height) = page.render_dimensions();
let crop_box = Rect::new(0.0, 0.0, f64::from(width), f64::from(height));
let initial_transform = page.initial_transform(true).to_kurbo();
let mut context = hayro_interpret::Context::new(initial_transform, source_crop_box, &cache, pdf.xref(), settings.clone());
let mut device = Collector {
max_paths: if selected.is_some() { limits.max_paths } else { limits.max_paths.saturating_sub(total_paths) },
max_elements: if selected.is_some() { limits.max_path_elements } else { limits.max_path_elements.saturating_sub(total_elements) },
..Collector::default()
};
crate::read::interpret_page(page, &mut context, &mut device)?;
if device.exceeded {
return Err(Error::OutputLimit);
}
if device.paths.len() > limits.max_paths || device.paths.iter().map(|path| path.elements().len()).sum::<usize>() > limits.max_path_elements {
return Err(Error::OutputLimit);
}
total_paths = total_paths.saturating_add(device.paths.len());
total_elements = device.paths.iter().fold(total_elements, |count, path| count.saturating_add(path.elements().len()));
if total_paths > limits.max_paths || total_elements > limits.max_path_elements {
return Err(Error::OutputLimit);
}
pages.push(Page {
crop_box,
paths: device.paths,
omitted_text: device.omitted_text,
omitted_images: device.omitted_images,
clips_seen: device.clips_seen,
});
}
let mut warnings = Vec::new();
if pages.iter().any(|page| page.omitted_text > 0) {
warnings.push("text glyphs are not returned as paths".into());
}
if pages.iter().any(|page| page.omitted_images > 0) {
warnings.push("raster images are not returned as paths".into());
}
if pages.iter().any(|page| page.clips_seen > 0) {
warnings.push("clip paths are not applied to extracted geometry".into());
}
if truncated.load(Ordering::Relaxed) {
warnings.push("PDF content was truncated by the interpreter".into());
}
Ok(Imported { pages, warnings })
}
#[derive(Default)]
struct Collector {
paths: Vec<BezPath>,
omitted_text: usize,
omitted_images: usize,
clips_seen: usize,
max_paths: usize,
max_elements: usize,
elements: usize,
exceeded: bool,
}
impl Device<'_> for Collector {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) {
if path.is_empty() {
return;
}
let count = path.elements().len();
if self.paths.len() >= self.max_paths || self.elements.saturating_add(count) > self.max_elements {
self.exceeded = true;
return;
}
self.elements = self.elements.saturating_add(count);
self.paths.push(transform * path.clone());
}
fn push_clip_path(&mut self, _: &ClipPath) {
self.clips_seen = self.clips_seen.saturating_add(1);
}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
self.omitted_text = self.omitted_text.saturating_add(1);
}
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {
self.omitted_images = self.omitted_images.saturating_add(1);
}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
+155
View File
@@ -30,6 +30,161 @@ use kurbo::{BezPath, PathEl, Point};
/// The SVG namespace.
pub const SVG_NS: &str = "http://www.w3.org/2000/svg";
/// A structured SVG scene. Applications provide geometry, styles and attributes; this module
/// owns the SVG element tree and serializes it with XML escaping.
#[derive(Clone, Debug, PartialEq)]
pub struct Scene {
pub width_mm: f64,
pub height_mm: f64,
pub view_box: [f64; 4],
pub elements: Vec<Element>,
}
/// Structured SVG elements supported by the path-document writer.
#[derive(Clone, Debug, PartialEq)]
pub enum Element {
Group { attributes: Vec<Attribute>, children: Vec<Element> },
Path { attributes: Vec<Attribute>, path: BezPath },
Rect { attributes: Vec<Attribute>, x: f64, y: f64, width: f64, height: f64 },
Text { attributes: Vec<Attribute>, x: f64, y: f64, text: String },
Title(String),
Style(String),
NestedSvg { attributes: Vec<Attribute>, children: Vec<Element> },
}
/// An escaped SVG attribute. Element names and attribute names are API constants and must be
/// valid XML names; values are always escaped by the shared writer.
#[derive(Clone, Debug, PartialEq)]
pub struct Attribute {
pub name: String,
pub value: Value,
}
/// Attribute values written by the shared serializer.
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Text(String),
Number(f64),
Millimetres(f64),
ViewBox([f64; 4]),
TranslateScale { x: f64, y: f64, scale_x: f64, scale_y: f64 },
}
impl Attribute {
pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
Self { name: name.into(), value: Value::Text(value.into()) }
}
pub fn number(name: impl Into<String>, value: f64) -> Self {
Self { name: name.into(), value: Value::Number(value) }
}
pub fn millimetres(name: impl Into<String>, value: f64) -> Self {
Self { name: name.into(), value: Value::Millimetres(value) }
}
pub fn view_box(bounds: [f64; 4]) -> Self {
Self { name: "viewBox".into(), value: Value::ViewBox(bounds) }
}
pub fn translate_scale(x: f64, y: f64, scale_x: f64, scale_y: f64) -> Self {
Self { name: "transform".into(), value: Value::TranslateScale { x, y, scale_x, scale_y } }
}
}
impl Scene {
/// Serialize the scene as an SVG 1.1 document.
pub fn write(&self) -> String {
let mut writer = SvgWriter::new();
writer
.root()
.attr("version", "1.1")
.attr("width", format!("{}mm", num(self.width_mm, 3)))
.attr("height", format!("{}mm", num(self.height_mm, 3)))
.attr("viewBox", view_box_data(self.view_box))
.open();
write_elements(&mut writer, &self.elements, 1);
writer.line(0);
writer.close();
writer.finish()
}
}
fn write_attributes<'a>(mut tag: craft_xml::Tag<'a>, attributes: &'a [Attribute]) -> craft_xml::Tag<'a> {
// SVG consumers disagree about duplicate attributes; make the last supplied value win.
for (index, attribute) in attributes.iter().enumerate() {
if attributes[index + 1..].iter().any(|later| later.name == attribute.name) {
continue;
}
tag = match &attribute.value {
Value::Text(value) => tag.attr(&attribute.name, value),
Value::Number(value) => tag.attr(&attribute.name, num(*value, 3)),
Value::Millimetres(value) => tag.attr(&attribute.name, format!("{}mm", num(*value, 3))),
Value::ViewBox(bounds) => tag.attr(&attribute.name, view_box_data(*bounds)),
Value::TranslateScale { x, y, scale_x, scale_y } => {
tag.attr(&attribute.name, format!("translate({} {}) scale({} {})", num(*x, 3), num(*y, 3), shortest(*scale_x), shortest(*scale_y)))
}
};
}
tag
}
fn shortest(value: f64) -> String {
if value.is_finite() { value.to_string() } else { "0".into() }
}
fn write_elements(writer: &mut SvgWriter, elements: &[Element], depth: usize) {
for element in elements {
writer.line(depth);
match element {
Element::Group { attributes, children } => {
write_attributes(writer.tag("g"), attributes).open();
write_elements(writer, children, depth + 1);
writer.line(depth);
writer.close();
}
Element::Path { attributes, path } => {
let mut attrs = vec![Attribute::text("d", path_data(path))];
attrs.extend(attributes.iter().cloned());
write_attributes(writer.tag("path"), &attrs).empty();
}
Element::Rect { attributes, x, y, width, height } => {
let mut attrs = vec![
Attribute::number("x", *x),
Attribute::number("y", *y),
Attribute::number("width", *width),
Attribute::number("height", *height),
];
attrs.extend(attributes.iter().cloned());
write_attributes(writer.tag("rect"), &attrs).empty();
}
Element::Text { attributes, x, y, text } => {
let mut attrs = vec![Attribute::number("x", *x), Attribute::number("y", *y)];
attrs.extend(attributes.iter().cloned());
write_attributes(writer.tag("text"), &attrs).open();
writer.text(text);
writer.close();
}
Element::Title(text) => {
writer.tag("title").open();
writer.text(text);
writer.close();
}
Element::Style(css) => {
writer.tag("style").open();
writer.text(css);
writer.close();
}
Element::NestedSvg { attributes, children } => {
write_attributes(writer.tag("svg"), attributes).open();
write_elements(writer, children, depth + 1);
writer.line(depth);
writer.close();
}
}
}
}
fn view_box_data(bounds: [f64; 4]) -> String {
format!("{} {} {} {}", shortest(bounds[0]), shortest(bounds[1]), shortest(bounds[2]), shortest(bounds[3]))
}
/// A number written with a fixed count of decimals. A value that is not finite is written as
/// zero, because SVG has no spelling for it.
#[derive(Debug, Clone, Copy, PartialEq)]