From b373553e988b9f8ff4ac2c09a98a80fc94d1620b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:54:43 -0500 Subject: [PATCH] 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) {} +}