diff --git a/Cargo.lock b/Cargo.lock index 2875c8577..4cbdad9e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index e42f65003..c8fd501b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/dxf/Cargo.toml b/crates/dxf/Cargo.toml index 70c4f1234..11de1c377 100644 --- a/crates/dxf/Cargo.toml +++ b/crates/dxf/Cargo.toml @@ -15,3 +15,4 @@ craft-codepage = { workspace = true } thiserror = { workspace = true } log = "0.4" encoding_rs = "0.8.35" +kurbo = { workspace = true } diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 0109e9040..4cf3e46e9 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -11,6 +11,7 @@ use std::io::Write as _; pub mod model; +pub mod paths; mod version; pub use version::Version; diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs new file mode 100644 index 000000000..d60d91456 --- /dev/null +++ b/crates/dxf/src/paths.rs @@ -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, + pub bounds: Option, + pub unit_to_mm: f64, + pub warnings: Vec, +} + +/// Parse bounded DXF input and extract visible planar linework. +pub fn import(bytes: &[u8]) -> Result { + 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::()); + 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) -> 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 { + 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, closed: bool) -> BezPath { + let vertices = vertices.into_iter().collect::>(); + 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::().sqrt(); + if !(minor_len.is_finite() && minor_len > 0.0) { + return BezPath::new(); + } + let major_len = major.iter().map(|v| v * v).sum::().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]] +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index fbe69c26f..6984fb576 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -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"] diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index e48532349..e7a8cee01 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -18,6 +18,8 @@ pub mod ascii85; mod container; mod dsc; +#[cfg(feature = "paths")] +pub mod paths; mod preview; mod rectangles; diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs new file mode 100644 index 000000000..e77b90b60 --- /dev/null +++ b/crates/eps/src/paths.rs @@ -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, + /// Origin-zero page bounds in PostScript points, using the same accepted DSC box as the + /// interpreter. + pub page_bounds: Rect, + pub warnings: Vec, + pub preview_fallback: bool, +} + +/// Interpret EPS/PostScript bytes and return their editable vector path geometry. +pub fn import(bytes: &[u8], limits: Limits) -> Result { + 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, + 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(()) +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 193aeb9df..b370416ab 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e052c7304..ed9f21a79 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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")] diff --git a/crates/pdf/src/paths.rs b/crates/pdf/src/paths.rs new file mode 100644 index 000000000..bbb93942e --- /dev/null +++ b/crates/pdf/src/paths.rs @@ -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, + 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, + pub warnings: Vec, +} + +/// Open and interpret vector content using shared PDF parsing and interpreter APIs. +pub fn import(bytes: &[u8], limits: Limits) -> Result { + 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 { + import_selected(bytes, limits, Some(page_index)) +} + +fn import_selected(bytes: &[u8], limits: Limits, selected: Option) -> Result { + 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::() > 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, + 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>) {} + 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>, _: 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) {} +} diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index 614642de5..469a4f8fa 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -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, +} + +/// Structured SVG elements supported by the path-document writer. +#[derive(Clone, Debug, PartialEq)] +pub enum Element { + Group { attributes: Vec, children: Vec }, + Path { attributes: Vec, path: BezPath }, + Rect { attributes: Vec, x: f64, y: f64, width: f64, height: f64 }, + Text { attributes: Vec, x: f64, y: f64, text: String }, + Title(String), + Style(String), + NestedSvg { attributes: Vec, children: Vec }, +} + +/// 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, value: impl Into) -> Self { + Self { name: name.into(), value: Value::Text(value.into()) } + } + pub fn number(name: impl Into, value: f64) -> Self { + Self { name: name.into(), value: Value::Number(value) } + } + pub fn millimetres(name: impl Into, 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)]