From b373553e988b9f8ff4ac2c09a98a80fc94d1620b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:54:43 -0500 Subject: [PATCH 1/9] Add shared vector path import APIs --- Cargo.lock | 372 +++++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + crates/dxf/Cargo.toml | 1 + crates/dxf/src/lib.rs | 1 + crates/dxf/src/paths.rs | 218 +++++++++++++++++++++++ crates/eps/Cargo.toml | 8 + crates/eps/src/lib.rs | 2 + crates/eps/src/paths.rs | 75 ++++++++ crates/pdf/Cargo.toml | 2 + crates/pdf/src/lib.rs | 2 + crates/pdf/src/paths.rs | 127 ++++++++++++++ 11 files changed, 801 insertions(+), 9 deletions(-) create mode 100644 crates/dxf/src/paths.rs create mode 100644 crates/eps/src/paths.rs create mode 100644 crates/pdf/src/paths.rs diff --git a/Cargo.lock b/Cargo.lock index cd280f90c..a0ad1277b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1384,6 +1384,7 @@ version = "0.1.0" dependencies = [ "craft-codepage", "encoding_rs", + "kurbo 0.13.1", "log", "thiserror 2.0.21", ] @@ -1400,6 +1401,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" @@ -1954,6 +1961,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" @@ -2010,7 +2028,7 @@ name = "craft-plate-test" version = "0.1.0" dependencies = [ "craft-cms", - "craft-pdf", + "craft-pdf 0.1.0", "hayro-syntax", "serde", "serde_json", @@ -2071,7 +2089,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", @@ -3442,7 +3460,7 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types 0.12.7", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", @@ -3670,6 +3688,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" @@ -3968,7 +3992,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", @@ -3985,7 +4009,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", @@ -4160,6 +4184,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" @@ -4168,7 +4214,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -4177,7 +4223,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -6085,6 +6131,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" @@ -7035,6 +7090,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" @@ -7053,7 +7119,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", @@ -8203,6 +8269,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" @@ -8646,6 +8722,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" @@ -9171,7 +9256,7 @@ dependencies = [ "base64 0.23.1", "data-url", "fontdb", - "harfrust", + "harfrust 0.12.0", "imagesize 0.15.0", "kurbo 0.13.1", "log", @@ -9218,6 +9303,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" @@ -9288,9 +9588,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", ] @@ -9448,6 +9752,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 46f2824e5..6bc7b282f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,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..045986b99 --- /dev/null +++ b/crates/dxf/src/paths.rs @@ -0,0 +1,218 @@ +//! 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; + +#[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 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 mut paths = Vec::new(); + let mut warnings = Vec::new(); + for record in &drawing.entities { + let entity = crate::model::Entity::read(record); + if !entity.common.visible || record.paper_space() { + continue; + } + 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() { + continue; + } + if paths.len().saturating_add(converted.len()) > MAX_PATHS { + return Err(Error::TooManyPaths); + } + paths.extend(converted); + } + 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 units_to_mm(code: i64, measurement: Option) -> f64 { + match code { + 0 if measurement == Some(0) => 25.4, + 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 => 1e9, + 4 | _ => 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![circle(ocs(normal, *center), *radius)] + } + E::Arc { center, radius, start_angle, end_angle } + if radius.is_finite() && *radius > 0.0 && start_angle.is_finite() && end_angle.is_finite() => + { + vec![arc(ocs(normal, *center), *radius, start_angle.to_radians(), end_angle.to_radians())] + } + E::LwPolyline(poly) => vec![polyline(poly.vertices.iter().map(|v| (ocs(normal, [v.x, v.y, poly.elevation]), v.bulge)), poly.closed)], + E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => { + vec![polyline(poly.vertices.iter().filter(|v| !v.is_face()).map(|v| (ocs(normal, v.location), v.bulge)), poly.closed())] + } + E::Ellipse { center, major, ratio, start, end } if ratio.is_finite() && *ratio > 0.0 => { + vec![ellipse(*center, *major, *ratio, *start, *end)] + } + _ => Vec::new(), + } +} + +fn ocs(normal: crate::model::Point, point: crate::model::Point) -> Point { + let p = crate::model::ocs_to_wcs(normal, point); + Point::new(p[0], p[1]) +} + +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; + 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, ratio: f64, start: f64, end: f64) -> BezPath { + let (cx, cy) = (center[0], center[1]); + let (ax, ay) = (major[0], major[1]); + let (bx, by) = (-ay * ratio, ax * ratio); + let point = |t: f64| Point::new(cx + ax * t.cos() + bx * t.sin(), cy + ay * t.cos() + by * 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; + 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(-ax * t.sin() + bx * t.cos(), -ay * t.sin() + by * 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 +} 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 acffdfcf2..6e5e91d93 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; pub use container::{DOS_HEADER, DOS_MAGIC, Sections, sections, wrap}; diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs new file mode 100644 index 000000000..241c812f7 --- /dev/null +++ b/crates/eps/src/paths.rs @@ -0,0 +1,75 @@ +//! 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; + +/// 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 in PostScript points, plus the page size and import notes. +#[derive(Debug, Clone)] +pub struct Imported { + pub paths: Vec, + pub page_points: (f64, f64), + 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_points = source.document.artboards.first().map_or((612.0, 792.0), |board| (board.rect.width(), board.rect.height())); + let mut paths = Vec::new(); + let mut total_elements = 0usize; + for layer in &source.document.layers { + collect(layer, &mut paths, &mut total_elements, limits)?; + } + Ok(Imported { paths, page_points, warnings: source.warnings, preview_fallback: source.preview }) +} + +fn collect(node: &vectorcraft_doc::Node, paths: &mut Vec, elements: &mut usize, limits: Limits) -> Result<(), Error> { + if !node.visible { + return Ok(()); + } + if let Some(data) = node.path_data() { + 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() { + for child in children { + collect(child, paths, elements, limits)?; + } + } + 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..bb51e2a0f --- /dev/null +++ b/crates/pdf/src/paths.rs @@ -0,0 +1,127 @@ +//! Bounded extraction of page vector paths from PDF content streams. +//! +//! Paths remain in PDF user-space points. Text glyphs and raster images are counted as omitted +//! content; clipping and paint are not applied. Applications own page selection and model mapping. + +use hayro_interpret::font::Glyph; +use hayro_interpret::{ + BlendMode, ClipPath, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, +}; +use kurbo::{Affine, BezPath, Rect}; + +/// 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 path output exceeds configured limit")] + OutputLimit, +} + +/// Extracted paths for one PDF page, with its intersected crop box in user-space 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 { + 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 }); + } + let cache = InterpreterCache::new(); + let mut pages = Vec::with_capacity(pdf.pages().len()); + let mut total_paths = 0usize; + let mut total_elements = 0usize; + for page in pdf.pages().iter() { + let crop_box = page.intersected_crop_box().to_kurbo(); + let mut context = hayro_interpret::Context::new(Affine::IDENTITY, crop_box, &cache, pdf.xref(), InterpreterSettings::default()); + let mut device = Collector::default(); + crate::read::interpret_page(page, &mut context, &mut device)?; + total_paths = total_paths.saturating_add(device.paths.len()); + total_elements = device.paths.iter().fold(total_elements, |n, path| n.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()); + } + Ok(Imported { pages, warnings }) +} + +#[derive(Default)] +struct Collector { + paths: Vec, + omitted_text: usize, + omitted_images: usize, + clips_seen: usize, +} + +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) { + let path = transform * path.clone(); + if !path.is_empty() { + self.paths.push(path); + } + } + 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) {} +} From f80d86172b89a9bd9cfcb68ab272f55d0c1c512e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:01:33 -0500 Subject: [PATCH 2/9] Expose PostScript page bounds in path imports --- crates/eps/src/paths.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs index 241c812f7..adbe294f3 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -3,7 +3,7 @@ //! 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; +use kurbo::{BezPath, Rect}; /// Limits on the extracted geometry. The interpreter has its own execution and allocation caps. #[derive(Debug, Clone, Copy)] @@ -33,7 +33,8 @@ pub enum Error { #[derive(Debug, Clone)] pub struct Imported { pub paths: Vec, - pub page_points: (f64, f64), + /// Page bounds in source PostScript points, including a nonzero or negative origin. + pub page_bounds: Rect, pub warnings: Vec, pub preview_fallback: bool, } @@ -44,13 +45,17 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { return Err(Error::InputLimit { limit: limits.max_input_bytes }); } let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; - let page_points = source.document.artboards.first().map_or((612.0, 792.0), |board| (board.rect.width(), board.rect.height())); + let page_bounds = source + .document + .artboards + .first() + .map_or(Rect::new(0.0, 0.0, 612.0, 792.0), |board| Rect::new(board.rect.x0, board.rect.y0, board.rect.x1, board.rect.y1)); let mut paths = Vec::new(); let mut total_elements = 0usize; for layer in &source.document.layers { collect(layer, &mut paths, &mut total_elements, limits)?; } - Ok(Imported { paths, page_points, warnings: source.warnings, preview_fallback: source.preview }) + Ok(Imported { paths, page_bounds, warnings: source.warnings, preview_fallback: source.preview }) } fn collect(node: &vectorcraft_doc::Node, paths: &mut Vec, elements: &mut usize, limits: Limits) -> Result<(), Error> { From 5082d8bd95807de5a851b6ab6ec3546fdf0ec5c4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:08:44 -0500 Subject: [PATCH 3/9] Add structured SVG scene writer --- crates/svg/src/write.rs | 146 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index 614642de5..ac884d8f2 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -30,6 +30,152 @@ 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> { + for attribute in attributes { + 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), num(*scale_x, 4), num(*scale_y, 4))) + } + }; + } + tag +} + +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 attributes = attributes.clone(); + write_attributes(writer.tag("path").attr("d", path_data(path)), &attributes).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!("{} {} {} {}", num(bounds[0], 3), num(bounds[1], 3), num(bounds[2], 3), num(bounds[3], 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)] From 54f3a37edb4472349418cc4a503890ac49d9bf84 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:11:26 -0500 Subject: [PATCH 4/9] Extend shared vector format boundaries --- crates/dxf/src/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index 045986b99..f34f0e62f 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -86,7 +86,7 @@ fn units_to_mm(code: i64, measurement: Option) -> f64 { 15 => 10_000.0, 16 => 100_000.0, 17 => 1e9, - 4 | _ => 1.0, + _ => 1.0, } } From 85ed941ac5a5c963949694a6a422390607ee5c1f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:30:17 -0500 Subject: [PATCH 5/9] Address vector import review findings --- crates/dxf/src/paths.rs | 32 ++++++++++++++++++++++++++++++-- crates/svg/src/write.rs | 6 +++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index f34f0e62f..1b57cec40 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -35,6 +35,7 @@ pub fn import(bytes: &[u8]) -> Result { let unit_to_mm = units_to_mm(drawing.header.insunits(), drawing.header.measurement()); let mut paths = Vec::new(); let mut warnings = Vec::new(); + let mut unsupported = 0usize; for record in &drawing.entities { let entity = crate::model::Entity::read(record); if !entity.common.visible || record.paper_space() { @@ -53,6 +54,17 @@ pub fn import(bytes: &[u8]) -> Result { return Err(Error::NonFinite); } if converted.is_empty() { + if matches!( + entity.kind, + crate::model::EntityKind::Other + | crate::model::EntityKind::Text(_) + | crate::model::EntityKind::MText(_) + | crate::model::EntityKind::Insert(_) + | crate::model::EntityKind::Dimension(_) + | crate::model::EntityKind::Hatch(_) + ) { + unsupported = unsupported.saturating_add(1); + } continue; } if paths.len().saturating_add(converted.len()) > MAX_PATHS { @@ -60,6 +72,9 @@ pub fn import(bytes: &[u8]) -> Result { } 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()); } @@ -67,9 +82,9 @@ pub fn import(bytes: &[u8]) -> Result { Ok(Imported { paths, bounds, unit_to_mm, warnings }) } -fn units_to_mm(code: i64, measurement: Option) -> f64 { +fn units_to_mm(code: i64, _measurement: Option) -> f64 { match code { - 0 if measurement == Some(0) => 25.4, + 0 => 1.0, 1 => 25.4, 2 => 304.8, 3 => 1_609_344.0, @@ -86,6 +101,13 @@ fn units_to_mm(code: i64, measurement: Option) -> f64 { 15 => 10_000.0, 16 => 100_000.0, 17 => 1e9, + 18 => 149_597_870_700.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, } } @@ -183,6 +205,9 @@ fn arc(center: Point, radius: f64, start: f64, end: f64) -> BezPath { 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; @@ -204,6 +229,9 @@ fn ellipse(center: crate::model::Point, major: crate::model::Point, ratio: f64, 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; diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index ac884d8f2..664ce8f01 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -114,13 +114,17 @@ fn write_attributes<'a>(mut tag: craft_xml::Tag<'a>, attributes: &'a [Attribute] 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), num(*scale_x, 4), num(*scale_y, 4))) + 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); From e3d76471f6c3bcd8e19fba308c62ec0050ae1b64 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:36:05 -0500 Subject: [PATCH 6/9] Fix vector import edge cases from review --- crates/dxf/src/paths.rs | 69 ++++++++++++++++++++++++++--------------- crates/eps/src/paths.rs | 49 +++++++++++++++++++++++------ crates/pdf/src/paths.rs | 44 +++++++++++++++++++++++--- crates/svg/src/write.rs | 11 +++++-- 4 files changed, 130 insertions(+), 43 deletions(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index 1b57cec40..01e555797 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -14,6 +14,8 @@ pub enum Error { 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, } @@ -41,6 +43,9 @@ pub fn import(bytes: &[u8]) -> Result { if !entity.common.visible || record.paper_space() { continue; } + if excessive_sweep(&entity.kind) { + 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(); @@ -54,17 +59,7 @@ pub fn import(bytes: &[u8]) -> Result { return Err(Error::NonFinite); } if converted.is_empty() { - if matches!( - entity.kind, - crate::model::EntityKind::Other - | crate::model::EntityKind::Text(_) - | crate::model::EntityKind::MText(_) - | crate::model::EntityKind::Insert(_) - | crate::model::EntityKind::Dimension(_) - | crate::model::EntityKind::Hatch(_) - ) { - unsupported = unsupported.saturating_add(1); - } + unsupported = unsupported.saturating_add(1); continue; } if paths.len().saturating_add(converted.len()) > MAX_PATHS { @@ -117,27 +112,44 @@ fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> match kind { E::Line { start, end } => vec![line(*start, *end)], E::Circle { center, radius } if radius.is_finite() && *radius > 0.0 => { - vec![circle(ocs(normal, *center), *radius)] + 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![arc(ocs(normal, *center), *radius, start_angle.to_radians(), end_angle.to_radians())] + 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::LwPolyline(poly) => vec![polyline(poly.vertices.iter().map(|v| (ocs(normal, [v.x, v.y, poly.elevation]), v.bulge)), poly.closed)], E::Polyline(poly) if !poly.is_mesh() && !poly.is_polyface() => { - vec![polyline(poly.vertices.iter().filter(|v| !v.is_face()).map(|v| (ocs(normal, v.location), v.bulge)), poly.closed())] + 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, *ratio, *start, *end)] + vec![ellipse(*center, *major, normal, *ratio, *start, *end)] } _ => Vec::new(), } } -fn ocs(normal: crate::model::Point, point: crate::model::Point) -> Point { - let p = crate::model::ocs_to_wcs(normal, point); - Point::new(p[0], p[1]) +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], y[0], x[1], 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 { @@ -220,11 +232,14 @@ fn arc_segments(path: &mut BezPath, center: Point, start: f64, sweep: f64, radiu } } -fn ellipse(center: crate::model::Point, major: crate::model::Point, ratio: f64, start: f64, end: f64) -> BezPath { - let (cx, cy) = (center[0], center[1]); - let (ax, ay) = (major[0], major[1]); - let (bx, by) = (-ay * ratio, ax * ratio); - let point = |t: f64| Point::new(cx + ax * t.cos() + bx * t.sin(), cy + ay * t.cos() + by * t.sin()); +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 bx = [minor[0] / minor_len * ratio, minor[1] / minor_len * ratio, minor[2] / minor_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 }; @@ -237,10 +252,14 @@ fn ellipse(center: crate::model::Point, major: crate::model::Point, ratio: f64, 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(-ax * t.sin() + bx * t.cos(), -ay * t.sin() + by * t.cos()); + 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/src/paths.rs b/crates/eps/src/paths.rs index adbe294f3..ad1f23793 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -45,24 +45,46 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { return Err(Error::InputLimit { limit: limits.max_input_bytes }); } let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; - let page_bounds = source - .document - .artboards - .first() - .map_or(Rect::new(0.0, 0.0, 612.0, 792.0), |board| Rect::new(board.rect.x0, board.rect.y0, board.rect.x1, board.rect.y1)); + let section = crate::sections(bytes).map_err(|e| Error::Parse(e.to_string()))?; + let source_box = crate::Comments::read(section.postscript).page_box().unwrap_or([0.0, 0.0, 612.0, 792.0]); + let [llx, _lly, urx, ury] = source_box; + let page_bounds = Rect::new(llx, lly, urx, ury); 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)?; + collect(layer, &mut paths, &mut total_elements, limits, &mut clipped, &mut omitted)?; } - Ok(Imported { paths, page_bounds, warnings: source.warnings, preview_fallback: source.preview }) + let inverse_page = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, llx, ury]); + for path in &mut paths { + *path = inverse_page * path.clone(); + } + 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) -> Result<(), Error> { +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()); @@ -72,9 +94,16 @@ fn collect(node: &vectorcraft_doc::Node, paths: &mut Vec, elements: &mu paths.push(path); } } else if let Some(children) = node.children() { - for child in children { - collect(child, paths, elements, limits)?; + 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/src/paths.rs b/crates/pdf/src/paths.rs index bb51e2a0f..9f7126013 100644 --- a/crates/pdf/src/paths.rs +++ b/crates/pdf/src/paths.rs @@ -8,6 +8,10 @@ use hayro_interpret::{ BlendMode, ClipPath, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, }; 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)] @@ -61,14 +65,31 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { return Err(Error::Pages { limit: limits.max_pages }); } 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 in pdf.pages().iter() { let crop_box = page.intersected_crop_box().to_kurbo(); - let mut context = hayro_interpret::Context::new(Affine::IDENTITY, crop_box, &cache, pdf.xref(), InterpreterSettings::default()); - let mut device = Collector::default(); + let mut context = hayro_interpret::Context::new(Affine::IDENTITY, crop_box, &cache, pdf.xref(), settings.clone()); + let mut device = Collector { + max_paths: limits.max_paths.saturating_sub(total_paths), + max_elements: 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); + } total_paths = total_paths.saturating_add(device.paths.len()); total_elements = device.paths.iter().fold(total_elements, |n, path| n.saturating_add(path.elements().len())); if total_paths > limits.max_paths || total_elements > limits.max_path_elements { @@ -92,6 +113,9 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { 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 }) } @@ -101,16 +125,26 @@ struct Collector { 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) { - let path = transform * path.clone(); - if !path.is_empty() { - self.paths.push(path); + 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); diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index 664ce8f01..b73c8c1bf 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -107,7 +107,11 @@ impl Scene { } fn write_attributes<'a>(mut tag: craft_xml::Tag<'a>, attributes: &'a [Attribute]) -> craft_xml::Tag<'a> { - for attribute in attributes { + // 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)), @@ -136,8 +140,9 @@ fn write_elements(writer: &mut SvgWriter, elements: &[Element], depth: usize) { writer.close(); } Element::Path { attributes, path } => { - let attributes = attributes.clone(); - write_attributes(writer.tag("path").attr("d", path_data(path)), &attributes).empty(); + 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![ From 7fbe3837bcfda6d85fb193402922e8f3cde8b94d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:39:40 -0500 Subject: [PATCH 7/9] Fix EPS path bounds variable --- crates/eps/src/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs index ad1f23793..c4adc0d1b 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -47,7 +47,7 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; let section = crate::sections(bytes).map_err(|e| Error::Parse(e.to_string()))?; let source_box = crate::Comments::read(section.postscript).page_box().unwrap_or([0.0, 0.0, 612.0, 792.0]); - let [llx, _lly, urx, ury] = source_box; + let [llx, lly, urx, ury] = source_box; let page_bounds = Rect::new(llx, lly, urx, ury); let mut paths = Vec::new(); let mut total_elements = 0usize; From 4546555126ccdac61af23a681ab1ff121f05fd4b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:42:34 -0500 Subject: [PATCH 8/9] Fix bounds and limits in shared vector readers --- crates/dxf/src/paths.rs | 64 ++++++++++++++++++++++++++++++++++++++--- crates/eps/src/paths.rs | 14 +++------ crates/pdf/src/paths.rs | 43 ++++++++++++++++++++------- crates/svg/src/write.rs | 2 +- 4 files changed, 98 insertions(+), 25 deletions(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index 01e555797..6b656d442 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -7,6 +7,8 @@ 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 { @@ -35,17 +37,24 @@ 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() { + 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(); @@ -62,6 +71,10 @@ pub fn import(bytes: &[u8]) -> Result { 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); } @@ -77,6 +90,48 @@ pub fn import(bytes: &[u8]) -> Result { 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, @@ -96,7 +151,7 @@ fn units_to_mm(code: i64, _measurement: Option) -> f64 { 15 => 10_000.0, 16 => 100_000.0, 17 => 1e9, - 18 => 149_597_870_700.0, + 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, @@ -138,7 +193,7 @@ fn entity_paths(kind: &crate::model::EntityKind, normal: crate::model::Point) -> 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], y[0], x[1], y[1], n[0] * z, n[1] * z]); + let transform = kurbo::Affine::new([x[0], x[1], y[0], y[1], n[0] * z, n[1] * z]); transform * path } @@ -238,7 +293,8 @@ fn ellipse(center: crate::model::Point, major: crate::model::Point, normal: crat if !(minor_len.is_finite() && minor_len > 0.0) { return BezPath::new(); } - let bx = [minor[0] / minor_len * ratio, minor[1] / minor_len * ratio, minor[2] / minor_len * ratio]; + 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)); diff --git a/crates/eps/src/paths.rs b/crates/eps/src/paths.rs index c4adc0d1b..e77b90b60 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -29,11 +29,12 @@ pub enum Error { OutputLimit, } -/// First-page path geometry in PostScript points, plus the page size and import notes. +/// First-page path geometry normalized to the top-left origin in PostScript points, plus notes. #[derive(Debug, Clone)] pub struct Imported { pub paths: Vec, - /// Page bounds in source PostScript points, including a nonzero or negative origin. + /// 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, @@ -45,10 +46,7 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { return Err(Error::InputLimit { limit: limits.max_input_bytes }); } let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; - let section = crate::sections(bytes).map_err(|e| Error::Parse(e.to_string()))?; - let source_box = crate::Comments::read(section.postscript).page_box().unwrap_or([0.0, 0.0, 612.0, 792.0]); - let [llx, lly, urx, ury] = source_box; - let page_bounds = Rect::new(llx, lly, urx, ury); + 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; @@ -56,10 +54,6 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { for layer in &source.document.layers { collect(layer, &mut paths, &mut total_elements, limits, &mut clipped, &mut omitted)?; } - let inverse_page = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, llx, ury]); - for path in &mut paths { - *path = inverse_page * path.clone(); - } let mut warnings = source.warnings; if clipped { warnings.push("EPS clipping groups are returned as unclipped artwork".into()); diff --git a/crates/pdf/src/paths.rs b/crates/pdf/src/paths.rs index 9f7126013..bbb93942e 100644 --- a/crates/pdf/src/paths.rs +++ b/crates/pdf/src/paths.rs @@ -1,11 +1,11 @@ //! Bounded extraction of page vector paths from PDF content streams. //! -//! Paths remain in PDF user-space points. Text glyphs and raster images are counted as omitted -//! content; clipping and paint are not applied. Applications own page selection and model mapping. +//! 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, + BlendMode, ClipPath, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, RectExt, SoftMask, TransformExt, }; use kurbo::{Affine, BezPath, Rect}; use std::sync::{ @@ -36,11 +36,13 @@ pub enum Error { 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, with its intersected crop box in user-space points. +/// 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, @@ -59,11 +61,23 @@ pub struct Imported { /// 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(); @@ -78,20 +92,29 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { let mut pages = Vec::with_capacity(pdf.pages().len()); let mut total_paths = 0usize; let mut total_elements = 0usize; - for page in pdf.pages().iter() { - let crop_box = page.intersected_crop_box().to_kurbo(); - let mut context = hayro_interpret::Context::new(Affine::IDENTITY, crop_box, &cache, pdf.xref(), settings.clone()); + 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: limits.max_paths.saturating_sub(total_paths), - max_elements: limits.max_path_elements.saturating_sub(total_elements), + 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, |n, path| n.saturating_add(path.elements().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); } diff --git a/crates/svg/src/write.rs b/crates/svg/src/write.rs index b73c8c1bf..469a4f8fa 100644 --- a/crates/svg/src/write.rs +++ b/crates/svg/src/write.rs @@ -182,7 +182,7 @@ fn write_elements(writer: &mut SvgWriter, elements: &[Element], depth: usize) { } fn view_box_data(bounds: [f64; 4]) -> String { - format!("{} {} {} {}", num(bounds[0], 3), num(bounds[1], 3), num(bounds[2], 3), num(bounds[3], 3)) + 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 From d6d14f839ab7354913dc439201872f5591952e25 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:23:20 -0500 Subject: [PATCH 9/9] Correct DXF gigametre conversion --- crates/dxf/src/paths.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dxf/src/paths.rs b/crates/dxf/src/paths.rs index 6b656d442..d60d91456 100644 --- a/crates/dxf/src/paths.rs +++ b/crates/dxf/src/paths.rs @@ -150,7 +150,7 @@ fn units_to_mm(code: i64, _measurement: Option) -> f64 { 14 => 100.0, 15 => 10_000.0, 16 => 100_000.0, - 17 => 1e9, + 17 => 1e12, 18 => 149_597_870_700_000.0, 19 => 9.460_730_472_580_8e18, 20 => 3.085_677_581_491_367e19,