diff --git a/Cargo.lock b/Cargo.lock index 718445073..ed7a6649e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,6 +1505,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-file-import" +version = "0.1.0" +dependencies = [ + "craft-dxf", + "craft-eps", + "craft-file-formats", + "craft-pdf 0.1.0", + "craft-raster-codecs", + "craft-svg", + "kurbo 0.13.1", + "thiserror 2.0.21", + "vectorcraft-geom", +] + [[package]] name = "craft-flags" version = "0.1.0" @@ -2337,6 +2352,8 @@ dependencies = [ "roxmltree 0.21.2", "thiserror 2.0.21", "usvg 0.48.1", + "vectorcraft-geom", + "vectorcraft-pathops", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 64da50d7a..ac6f09673 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,8 +45,11 @@ craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } craft-file-formats = { path = "crates/file-formats" } +craft-file-import = { path = "crates/file-import" } vectorcraft-doc = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } vectorcraft-eps = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } +vectorcraft-geom = { git = "https://github.com/storytold/vectorcraft.git", rev = "6b3a6b8788ebcb420d69449199cbdf323888d27e" } +vectorcraft-pathops = { 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/eps/src/paths.rs b/crates/eps/src/paths.rs index e77b90b60..271f905bd 100644 --- a/crates/eps/src/paths.rs +++ b/crates/eps/src/paths.rs @@ -47,12 +47,16 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result { } let source = vectorcraft_eps::import(bytes).map_err(Error::Parse)?; let page_bounds = vectorcraft_eps::page(bytes).map_err(Error::Parse)?; + // Illustrator's editable copy is expressed in its art-box document coordinates. The page + // reader, in contrast, returns a page at the origin. Normalize extracted paths to match it. + let origin = source.document.artboards.first().map(|board| board.rect.origin()).unwrap_or_default(); + let to_page = kurbo::Affine::translate((-origin.x, -origin.y)); let mut paths = Vec::new(); let mut total_elements = 0usize; let mut clipped = false; let mut omitted = false; for layer in &source.document.layers { - collect(layer, &mut paths, &mut total_elements, limits, &mut clipped, &mut omitted)?; + collect(layer, &mut paths, &mut total_elements, limits, to_page, &mut clipped, &mut omitted)?; } let mut warnings = source.warnings; if clipped { @@ -69,6 +73,7 @@ fn collect( paths: &mut Vec, elements: &mut usize, limits: Limits, + to_page: kurbo::Affine, clipped: &mut bool, omitted: &mut bool, ) -> Result<(), Error> { @@ -85,7 +90,7 @@ fn collect( if paths.len() >= limits.max_paths || *elements > limits.max_path_elements { return Err(Error::OutputLimit); } - paths.push(path); + paths.push(to_page * path); } } else if let Some(children) = node.children() { let is_clip = matches!(node.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. } | vectorcraft_doc::NodeKind::Layer { clip: true, .. }); @@ -94,7 +99,7 @@ fn collect( if is_clip && index == 0 { continue; } - collect(child, paths, elements, limits, clipped, omitted)?; + collect(child, paths, elements, limits, to_page, clipped, omitted)?; } } else { *omitted = true; diff --git a/crates/file-import/Cargo.toml b/crates/file-import/Cargo.toml new file mode 100644 index 000000000..011943521 --- /dev/null +++ b/crates/file-import/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "craft-file-import" +description = "Shared format registry and bounded neutral scene import dispatch" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +craft-file-formats = { workspace = true } +craft-raster-codecs = { workspace = true } +craft-svg = { workspace = true } +craft-dxf = { workspace = true } +craft-pdf = { workspace = true, features = ["paths", "write"] } +craft-eps = { workspace = true, features = ["paths"] } +kurbo = { workspace = true } +thiserror = { workspace = true } +vectorcraft-geom = { workspace = true } + +[lints] +workspace = true diff --git a/crates/file-import/src/lib.rs b/crates/file-import/src/lib.rs new file mode 100644 index 000000000..195363da0 --- /dev/null +++ b/crates/file-import/src/lib.rs @@ -0,0 +1,405 @@ +//! Shared format selection and bounded decoding into neutral vector scenes or raster pixels. +//! +//! Applications own only the mapping from these generic results into their document models and +//! product-specific placement options. Format detection, supported import capabilities, reader +//! selection, unit normalization, and raster codec dispatch live here. + +use kurbo::{Affine, BezPath, PathEl, Point}; + +pub mod write; +pub use craft_file_formats::Format as DetectedFormat; + +/// The maximum encoded input accepted by the import facade. +pub const MAX_INPUT_BYTES: usize = 64 * 1024 * 1024; +pub const MAX_SHAPES: usize = 200_000; +const MM_PER_PT: f64 = 25.4 / 72.0; + +/// A product-neutral input family supported by this reader registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportFormat { + Svg, + Svgz, + Dxf, + Pdf, + Eps, + Raster, +} + +/// Reader and writer capabilities for one family in the shared registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Capability { + pub format: ImportFormat, + pub detected_formats: &'static [craft_file_formats::Format], + pub extensions: &'static [&'static str], + pub write_extensions: &'static [&'static str], + pub mime_types: &'static [&'static str], + pub raster: bool, + pub vector: bool, +} + +/// Format, reader and writer support for callers that build dialogs or query capabilities. +pub const CAPABILITIES: &[Capability] = &[ + Capability { + format: ImportFormat::Svg, + detected_formats: &[craft_file_formats::Format::Svg], + extensions: &["svg"], + write_extensions: &["svg"], + mime_types: &["image/svg+xml"], + raster: false, + vector: true, + }, + Capability { + format: ImportFormat::Svgz, + detected_formats: &[craft_file_formats::Format::Svgz], + extensions: &["svgz"], + write_extensions: &["svgz"], + mime_types: &["image/svg+xml"], + raster: false, + vector: true, + }, + Capability { + format: ImportFormat::Dxf, + detected_formats: &[craft_file_formats::Format::Dxf], + extensions: &["dxf"], + write_extensions: &["dxf"], + mime_types: &["image/vnd.dxf", "application/dxf"], + raster: false, + vector: true, + }, + Capability { + format: ImportFormat::Pdf, + detected_formats: &[craft_file_formats::Format::Pdf], + extensions: &["pdf"], + write_extensions: &["pdf"], + mime_types: &["application/pdf"], + raster: false, + vector: true, + }, + Capability { + format: ImportFormat::Eps, + detected_formats: &[craft_file_formats::Format::Eps, craft_file_formats::Format::PostScript, craft_file_formats::Format::Illustrator], + extensions: &["eps", "ps", "ai"], + write_extensions: &[], + mime_types: &["application/postscript"], + raster: false, + vector: true, + }, + Capability { + format: ImportFormat::Raster, + detected_formats: &[ + craft_file_formats::Format::Png, + craft_file_formats::Format::Jpeg, + craft_file_formats::Format::Bmp, + craft_file_formats::Format::Gif, + craft_file_formats::Format::Tiff, + craft_file_formats::Format::WebP, + ], + extensions: &["png", "jpg", "jpeg", "bmp", "gif", "tif", "tiff", "webp"], + write_extensions: &["png", "jpg", "jpeg"], + mime_types: &["image/png", "image/jpeg", "image/bmp", "image/gif", "image/tiff", "image/webp"], + raster: true, + vector: false, + }, +]; + +/// A vector path with generic source metadata. `tag` and `layer` are source labels; applications +/// may interpret their own conventions while the geometry and parsing stay shared. +#[derive(Debug, Clone, PartialEq)] +pub struct SceneShape { + pub path: BezPath, + pub tag: Option, + pub class: Option, + pub stroke: Option, + /// Parsed RGB stroke color when the source stroke is a concrete CSS color. + pub stroke_rgb: Option<[u8; 3]>, + pub layer: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VectorScene { + pub format: ImportFormat, + pub width_mm: f64, + pub height_mm: f64, + pub shapes: Vec, + pub warnings: Vec, + /// Additional warnings omitted by the SVG reader's bounded warning collection. + pub dropped_warnings: usize, +} + +#[derive(Debug, Clone)] +pub struct RasterImage { + pub format: ImportFormat, + pub width: u32, + pub height: u32, + /// Straight-alpha RGBA pixels, row-major. + pub rgba: Vec, +} + +#[derive(Debug, Clone)] +pub enum Imported { + Vector(VectorScene), + Raster(RasterImage), +} + +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub max_input_bytes: usize, + pub max_shapes: usize, + pub max_raster_width: u32, + pub max_raster_height: u32, + pub max_raster_pixels: u64, + pub max_raster_alloc_bytes: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_input_bytes: MAX_INPUT_BYTES, + max_shapes: MAX_SHAPES, + max_raster_width: 10_000, + max_raster_height: 10_000, + max_raster_pixels: 16_000_000, + max_raster_alloc_bytes: 256 * 1024 * 1024, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unrecognized or unsupported import format")] + Unsupported, + #[error("input is {size} bytes; limit is {limit}")] + InputLimit { size: usize, limit: usize }, + #[error("{0}")] + Decode(String), + #[error("import produced invalid or non-finite geometry")] + InvalidGeometry, + #[error("import exceeds the configured shape limit")] + ShapeLimit, + #[error("format writer failed: {0}")] + Write(String), +} + +impl From for Error { + fn from(error: craft_pdf::write::WriteError) -> Self { + Self::Write(error.to_string()) + } +} + +/// Resolve detected file kinds to this reader registry. Illustrator files with PDF content use +/// the PDF path reader; PostScript-family files use the EPS/PostScript interpreter. +pub fn format_for(name: &str, bytes: &[u8]) -> Option { + use craft_file_formats::Format as F; + match craft_file_formats::detect(name, bytes)? { + F::Svg => Some(ImportFormat::Svg), + F::Svgz => Some(ImportFormat::Svgz), + F::Dxf => Some(ImportFormat::Dxf), + F::Pdf => Some(ImportFormat::Pdf), + F::Illustrator if bytes.starts_with(b"%PDF-") => Some(ImportFormat::Pdf), + F::Eps | F::PostScript | F::Illustrator => Some(ImportFormat::Eps), + F::Png | F::Jpeg | F::Bmp | F::Gif | F::Tiff | F::WebP => Some(ImportFormat::Raster), + _ => None, + } +} + +/// Return supported extensions in the same registry used by [`format_for`]. +pub fn supported_extensions() -> Vec<&'static str> { + CAPABILITIES.iter().flat_map(|capability| capability.extensions.iter().copied()).collect() +} + +/// Raw detected kinds routed to one reader family, for app open-dialog and capability adapters. +pub fn supported_formats(format: ImportFormat) -> &'static [craft_file_formats::Format] { + CAPABILITIES.iter().find(|capability| capability.format == format).map_or(&[], |capability| capability.detected_formats) +} + +/// Whether the registry has a writer for an extension. +pub fn supports_write_extension(extension: &str) -> bool { + let extension = extension.trim_start_matches('.'); + CAPABILITIES.iter().any(|capability| capability.write_extensions.iter().any(|candidate| candidate.eq_ignore_ascii_case(extension))) +} + +/// Whether a family has at least one output writer in the shared registry. +pub fn supports_writer(format: ImportFormat) -> bool { + CAPABILITIES.iter().find(|capability| capability.format == format).is_some_and(|capability| !capability.write_extensions.is_empty()) +} + +/// Detect, select a reader, and decode the input into a neutral vector scene or RGBA image. +pub fn import(name: &str, bytes: &[u8], limits: Limits) -> Result { + if bytes.len() > limits.max_input_bytes { + return Err(Error::InputLimit { size: bytes.len(), limit: limits.max_input_bytes }); + } + let format = format_for(name, bytes).ok_or(Error::Unsupported)?; + let imported = match format { + ImportFormat::Svg | ImportFormat::Svgz => import_svg(name, bytes, format, limits), + ImportFormat::Dxf => import_dxf(bytes, format, limits), + ImportFormat::Pdf => import_pdf(bytes, format, limits), + ImportFormat::Eps => import_eps(bytes, format, limits), + ImportFormat::Raster => import_raster(bytes, format, limits), + }?; + if let Imported::Vector(scene) = &imported + && (!(scene.width_mm.is_finite() && scene.height_mm.is_finite() && scene.width_mm > 0.0 && scene.height_mm > 0.0) + || scene.shapes.iter().any(|shape| !path_is_finite(&shape.path))) + { + return Err(Error::InvalidGeometry); + } + Ok(imported) +} + +#[derive(Debug, Clone, Default)] +struct SvgTag { + data_kind: Option, + class: Option, +} + +impl craft_svg::paths::Kind for SvgTag { + const PROPERTIES: &'static [&'static str] = &["data-kind"]; + + fn of(style: &craft_svg::paths::StyleMap, class: &str) -> Option { + let data_kind = style.get("data-kind").cloned(); + let class = (!class.is_empty()).then(|| class.to_owned()); + (data_kind.is_some() || class.is_some()).then_some(Self { data_kind, class }) + } +} + +fn import_svg(name: &str, bytes: &[u8], format: ImportFormat, limits: Limits) -> Result { + let text = craft_svg::svgz::text(bytes, limits.max_input_bytes).map_err(|error| Error::Decode(error.to_string()))?; + let imported = craft_svg::paths::import::(&text, name).map_err(|error| Error::Decode(error.to_string()))?; + if imported.shapes.len() > limits.max_shapes { + return Err(Error::ShapeLimit); + } + let mut shapes = Vec::with_capacity(imported.shapes.len()); + for shape in &imported.shapes { + let path = imported.clipped_path(shape).map_err(|error| Error::Decode(error.to_string()))?; + shapes.push(SceneShape { + path, + tag: shape.kind.data_kind.clone(), + class: shape.kind.class.clone(), + stroke: shape.stroke.clone(), + stroke_rgb: shape.stroke.as_deref().and_then(|value| craft_svg::color::parse_color(value).ok()).map(|color| [color.r, color.g, color.b]), + layer: shape.layer.clone(), + }); + } + Ok(Imported::Vector(VectorScene { + format, + width_mm: imported.width_mm, + height_mm: imported.height_mm, + shapes, + warnings: imported.warnings, + dropped_warnings: imported.dropped_warnings, + })) +} + +fn import_dxf(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result { + let imported = craft_dxf::paths::import(bytes).map_err(|error| Error::Decode(error.to_string()))?; + if imported.paths.len() > limits.max_shapes { + return Err(Error::ShapeLimit); + } + let (width_mm, height_mm, transform) = match imported.bounds { + Some(bounds) => ( + (bounds.width() * imported.unit_to_mm).max(1.0), + (bounds.height() * imported.unit_to_mm).max(1.0), + Affine::new([imported.unit_to_mm, 0.0, 0.0, -imported.unit_to_mm, -bounds.x0 * imported.unit_to_mm, bounds.y1 * imported.unit_to_mm]), + ), + None => (100.0, 150.0, Affine::IDENTITY), + }; + let shapes = imported + .paths + .into_iter() + .map(|path| SceneShape { path: transform * path, tag: None, class: None, stroke: None, stroke_rgb: None, layer: None }) + .collect(); + Ok(Imported::Vector(VectorScene { format, width_mm, height_mm, shapes, warnings: imported.warnings, dropped_warnings: 0 })) +} + +fn import_pdf(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result { + let imported = craft_pdf::paths::import_page( + bytes, + 0, + craft_pdf::paths::Limits { max_input_bytes: limits.max_input_bytes, max_paths: limits.max_shapes, ..Default::default() }, + ) + .map_err(|error| Error::Decode(error.to_string()))?; + let page = imported.pages.into_iter().next().ok_or_else(|| Error::Decode("PDF has no pages".into()))?; + let bounds = page.crop_box; + let mut warnings = imported.warnings; + if page.omitted_text > 0 || page.omitted_images > 0 { + warnings.push(format!("PDF omitted {} text and {} image items", page.omitted_text, page.omitted_images)); + } + let shapes = page + .paths + .into_iter() + .map(|path| SceneShape { path: Affine::scale(MM_PER_PT) * path, tag: None, class: None, stroke: None, stroke_rgb: None, layer: None }) + .collect(); + Ok(Imported::Vector(VectorScene { + format, + width_mm: bounds.width() * MM_PER_PT, + height_mm: bounds.height() * MM_PER_PT, + shapes, + warnings, + dropped_warnings: 0, + })) +} + +fn import_eps(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result { + let imported = craft_eps::paths::import( + bytes, + craft_eps::paths::Limits { max_input_bytes: limits.max_input_bytes, max_paths: limits.max_shapes, ..Default::default() }, + ) + .map_err(|error| Error::Decode(error.to_string()))?; + let bounds = imported.page_bounds; + let transform = Affine::scale(MM_PER_PT); + let mut warnings = imported.warnings; + if imported.preview_fallback { + warnings.push("embedded raster preview was not imported as paths".into()); + } + let shapes = imported + .paths + .into_iter() + .map(|path| SceneShape { path: transform * path, tag: None, class: None, stroke: None, stroke_rgb: None, layer: None }) + .collect(); + Ok(Imported::Vector(VectorScene { + format, + width_mm: (bounds.width() * MM_PER_PT).max(1.0), + height_mm: (bounds.height() * MM_PER_PT).max(1.0), + shapes, + warnings, + dropped_warnings: 0, + })) +} + +fn import_raster(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result { + let rgba = craft_raster_codecs::common::decode_rgba( + bytes, + craft_raster_codecs::common::DecodeLimits { + max_file_bytes: limits.max_input_bytes, + max_width: limits.max_raster_width, + max_height: limits.max_raster_height, + max_pixels: limits.max_raster_pixels, + max_alloc_bytes: limits.max_raster_alloc_bytes, + }, + ) + .map_err(|error| Error::Decode(error.to_string()))?; + let (width, height) = rgba.dimensions(); + Ok(Imported::Raster(RasterImage { format, width, height, rgba: rgba.into_raw() })) +} + +fn path_is_finite(path: &BezPath) -> bool { + path.elements().iter().all(|element| { + let finite = |point: Point| point.x.is_finite() && point.y.is_finite(); + match *element { + PathEl::MoveTo(point) | PathEl::LineTo(point) => finite(point), + PathEl::QuadTo(a, b) => finite(a) && finite(b), + PathEl::CurveTo(a, b, c) => finite(a) && finite(b) && finite(c), + PathEl::ClosePath => true, + } + }) +} + +#[cfg(test)] +mod format_tests { + use super::*; + + #[test] + fn pdf_based_illustrator_is_routed_to_pdf_reader() { + let bytes = b"%PDF-1.7\n/AIPrivateData"; + assert_eq!(format_for("illustration.ai", bytes), Some(ImportFormat::Pdf)); + } +} diff --git a/crates/file-import/src/write.rs b/crates/file-import/src/write.rs new file mode 100644 index 000000000..676206282 --- /dev/null +++ b/crates/file-import/src/write.rs @@ -0,0 +1,447 @@ +//! Shared writer selection and serialization for neutral scenes and pixels. + +use kurbo::{BezPath, PathEl, Point}; + +use crate::{CAPABILITIES, Error, ImportFormat}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteFormat { + Svg, + Svgz, + Dxf, + Pdf, + Png, + Jpeg, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OutputLayer { + /// Product-facing name. SVG stores it as `data-layer`. + pub name: String, + /// Already mapped/sanitized format name, supplied by the application adapter. + pub dxf_name: String, + pub visible: bool, + pub dxf_color_index: u8, + pub class: Option, + pub data_kind: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OutputPath { + pub path: BezPath, + pub layer: usize, + pub color_rgb: [u8; 3], + pub stroke_width_mm: f64, + pub class: Option, + pub data_kind: Option, + /// Close each open contour for formats that require closed cutting operations. + pub close_open_contours: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OutputScene { + pub width_mm: f64, + pub height_mm: f64, + pub view_box: [f64; 4], + pub layers: Vec, + pub paths: Vec, +} + +/// One page for the shared PDF path writer. Coordinates and stroke widths are millimetres. +#[derive(Debug, Clone, PartialEq)] +pub struct PdfPage { + pub width_mm: f64, + pub height_mm: f64, + pub view_box: [f64; 4], + pub paths: Vec, +} + +/// A PDF path with either a fill or a stroke. `None` fills the path; `Some(width)` strokes it. +#[derive(Debug, Clone, PartialEq)] +pub struct PdfPath { + pub path: BezPath, + pub color_rgb: [u8; 3], + pub stroke_width_mm: Option, +} + +/// Match a target extension to the centralized writer registry. +pub fn writer_format_for_extension(extension: &str) -> Option { + let family = writer_family_for_extension(extension)?; + let extension = extension.trim_start_matches('.'); + Some(match family { + ImportFormat::Svg => WriteFormat::Svg, + ImportFormat::Svgz => WriteFormat::Svgz, + ImportFormat::Dxf => WriteFormat::Dxf, + ImportFormat::Pdf => WriteFormat::Pdf, + ImportFormat::Raster if extension.eq_ignore_ascii_case("png") => WriteFormat::Png, + ImportFormat::Raster => WriteFormat::Jpeg, + ImportFormat::Eps => return None, + }) +} + +/// Product handler family for a writer extension. +pub fn writer_family_for_extension(extension: &str) -> Option { + let extension = extension.trim_start_matches('.'); + for capability in CAPABILITIES { + if !capability.write_extensions.iter().any(|candidate| candidate.eq_ignore_ascii_case(extension)) { + continue; + } + return Some(capability.format); + } + None +} + +/// Vector extensions the writer registry supports. +pub fn supported_vector_write_extensions() -> Vec<&'static str> { + CAPABILITIES.iter().filter(|capability| capability.vector).flat_map(|capability| capability.write_extensions.iter().copied()).collect() +} + +/// Serialize a neutral vector scene as SVG, SVGZ, DXF, or PDF according to the extension. +pub fn write_vector(extension: &str, scene: &OutputScene) -> Result, Error> { + match writer_format_for_extension(extension).ok_or_else(|| Error::Write(format!("no writer for .{extension}")))? { + WriteFormat::Svg => write_svg(scene).map(String::into_bytes), + WriteFormat::Svgz => write_svg(scene).map(|svg| craft_svg::svgz::compress(svg.as_bytes())), + WriteFormat::Dxf => write_dxf(scene).map(String::into_bytes), + WriteFormat::Pdf => write_pdf(scene), + WriteFormat::Png | WriteFormat::Jpeg => Err(Error::Write("raster output requires pixels".into())), + } +} + +/// Encode an RGBA image using the writer selected by its extension. +pub fn write_raster(extension: &str, width: u32, height: u32, rgba: &[u8], jpeg_quality: u8) -> Result, Error> { + let pixels = craft_raster_codecs::common::Pixels::from_raw(width, height, rgba.to_vec()).ok_or(Error::InvalidGeometry)?; + match writer_format_for_extension(extension).ok_or_else(|| Error::Write(format!("no writer for .{extension}")))? { + WriteFormat::Png => craft_raster_codecs::common::encode_png(&pixels).map_err(|error| Error::Write(error.to_string())), + WriteFormat::Jpeg => craft_raster_codecs::common::encode_jpeg(&pixels, jpeg_quality).map_err(|error| Error::Write(error.to_string())), + _ => Err(Error::Write("vector output requires paths".into())), + } +} + +fn write_svg(scene: &OutputScene) -> Result { + validate_scene(scene)?; + let view_scale = (scene.width_mm / scene.view_box[2]).min(scene.height_mm / scene.view_box[3]); + if !(view_scale.is_finite() && view_scale > 0.0) { + return Err(Error::InvalidGeometry); + } + let paths_by_layer = paths_by_layer(scene); + let elements = scene + .layers + .iter() + .enumerate() + .map(|(layer_index, layer)| { + let paths = paths_by_layer[layer_index] + .iter() + .map(|path| { + let color = path.color_rgb; + let mut attributes = vec![ + craft_svg::write::Attribute::text("fill", "none"), + craft_svg::write::Attribute::text("stroke", format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2])), + craft_svg::write::Attribute::text("stroke-width", format_float(path.stroke_width_mm / view_scale)), + ]; + if let Some(class) = &path.class { + attributes.push(craft_svg::write::Attribute::text("class", class.clone())); + } + if let Some(data_kind) = &path.data_kind { + attributes.push(craft_svg::write::Attribute::text("data-kind", data_kind.clone())); + } + craft_svg::write::Element::Path { attributes, path: path.path.clone() } + }) + .collect::>(); + let mut attributes = vec![ + craft_svg::write::Attribute::text("data-layer", layer.name.clone()), + craft_svg::write::Attribute::text("style", if layer.visible { "display:inline" } else { "display:none" }), + ]; + if let Some(class) = &layer.class { + attributes.push(craft_svg::write::Attribute::text("class", class.clone())); + } + if let Some(data_kind) = &layer.data_kind { + attributes.push(craft_svg::write::Attribute::text("data-kind", data_kind.clone())); + } + craft_svg::write::Element::Group { attributes, children: paths } + }) + .collect(); + Ok(craft_svg::write::Scene { width_mm: scene.width_mm, height_mm: scene.height_mm, view_box: scene.view_box, elements }.write()) +} + +fn write_pdf(scene: &OutputScene) -> Result, Error> { + validate_scene(scene)?; + let paths_by_layer = paths_by_layer(scene); + // Match SVG's painter order: layers in order, then paths in source order within each layer. + let paths = scene + .layers + .iter() + .enumerate() + .filter(|(_, layer)| layer.visible) + .flat_map(|(layer_index, _)| paths_by_layer[layer_index].iter().copied()) + .map(|path| PdfPath { path: path.path.clone(), color_rgb: path.color_rgb, stroke_width_mm: Some(path.stroke_width_mm) }) + .collect(); + write_pdf_pages(&[PdfPage { width_mm: scene.width_mm, height_mm: scene.height_mm, view_box: scene.view_box, paths }]) +} + +/// Group the neutral paths once so writing does not rescan all paths for every layer. +fn paths_by_layer(scene: &OutputScene) -> Vec> { + let mut grouped: Vec> = (0..scene.layers.len()).map(|_| Vec::new()).collect(); + for path in &scene.paths { + if let Some(layer) = grouped.get_mut(path.layer) { + layer.push(path); + } + } + grouped +} + +/// Encode a neutral list of laid-out pages with vector paths as PDF. +pub fn write_pdf_pages(pages: &[PdfPage]) -> Result, Error> { + use craft_pdf::write::{Metadata, Writer, backend}; + const PT_PER_MM: f64 = 72.0 / 25.4; + craft_pdf::write::guard(|| { + let settings = backend::SerializeSettings { + configuration: craft_pdf::write::configuration(backend::configure::PdfVersion::Pdf14, None)?, + ..Default::default() + }; + let mut writer = Writer::new(settings, Metadata::default()); + for source_page in pages { + let [view_x, view_y, view_width, view_height] = source_page.view_box; + if !(source_page.width_mm.is_finite() + && source_page.height_mm.is_finite() + && source_page.width_mm > 0.0 + && source_page.height_mm > 0.0 + && view_x.is_finite() + && view_y.is_finite() + && view_width.is_finite() + && view_height.is_finite() + && view_width > 0.0 + && view_height > 0.0) + { + return Err(Error::InvalidGeometry); + } + let width = source_page.width_mm * PT_PER_MM; + let height = source_page.height_mm * PT_PER_MM; + if !(width.is_finite() + && height.is_finite() + && width > 0.0 + && height > 0.0 + && width <= f64::from(f32::MAX) + && height <= f64::from(f32::MAX)) + { + return Err(Error::Write("page dimensions are outside the PDF range".into())); + } + let page_settings = backend::page::PageSettings::from_wh(width as f32, height as f32) + .ok_or_else(|| Error::Write("page dimensions are not finite or positive".into()))?; + let mut page = writer.document_mut().start_page_with(page_settings); + let mut surface = page.surface(); + let view_scale = (source_page.width_mm / view_width).min(source_page.height_mm / view_height); + let offset_x = (source_page.width_mm - view_width * view_scale) / 2.0; + let offset_y = (source_page.height_mm - view_height * view_scale) / 2.0; + if !(view_scale.is_finite() && view_scale > 0.0 && offset_x.is_finite() && offset_y.is_finite()) { + return Err(Error::InvalidGeometry); + } + for source in &source_page.paths { + let mut path = backend::geom::PathBuilder::new(); + for element in source.path.elements() { + let point = |p: Point| -> Result<(f32, f32), Error> { + let x = ((p.x - view_x) * view_scale + offset_x) * PT_PER_MM; + let y = ((p.y - view_y) * view_scale + offset_y) * PT_PER_MM; + if !(p.x.is_finite() + && p.y.is_finite() + && x.is_finite() + && y.is_finite() + && x.abs() <= f64::from(f32::MAX) + && y.abs() <= f64::from(f32::MAX)) + { + return Err(Error::Write("path coordinate is outside the PDF range".into())); + } + Ok((x as f32, y as f32)) + }; + match *element { + PathEl::MoveTo(p) => { + let (x, y) = point(p)?; + path.move_to(x, y); + } + PathEl::LineTo(p) => { + let (x, y) = point(p)?; + path.line_to(x, y); + } + PathEl::QuadTo(a, b) => { + let (x1, y1) = point(a)?; + let (x, y) = point(b)?; + path.quad_to(x1, y1, x, y); + } + PathEl::CurveTo(a, b, c) => { + let (x1, y1) = point(a)?; + let (x2, y2) = point(b)?; + let (x, y) = point(c)?; + path.cubic_to(x1, y1, x2, y2, x, y); + } + PathEl::ClosePath => path.close(), + } + } + if let Some(path) = path.finish() { + let color = backend::color::rgb::Color::new(source.color_rgb[0], source.color_rgb[1], source.color_rgb[2]); + match source.stroke_width_mm { + Some(width_mm) => { + let width_pt = width_mm * PT_PER_MM; + if !(width_mm.is_finite() && width_mm > 0.0 && width_pt.is_finite() && width_pt <= f64::from(f32::MAX)) { + return Err(Error::Write("stroke width must be finite and positive".into())); + } + surface.set_fill(None); + surface.set_stroke(Some(backend::paint::Stroke { paint: color.into(), width: width_pt as f32, ..Default::default() })); + } + None => { + surface.set_stroke(None); + surface.set_fill(Some(backend::paint::Fill { paint: color.into(), ..Default::default() })); + } + } + surface.draw_path(&path); + } + } + surface.finish(); + page.finish(); + } + writer.finish().map_err(|error| Error::Write(error.to_string())) + }) +} + +fn write_dxf(scene: &OutputScene) -> Result { + validate_scene(scene)?; + let mut drawing = craft_dxf::model::write::Drawing::default(); + drawing.format = craft_dxf::model::write::Format::R14; + drawing.insunits = 4; + drawing.metric = true; + drawing.extents = [0.0, 0.0, scene.width_mm, scene.height_mm]; + drawing.limits = drawing.extents; + for layer in &scene.layers { + drawing.layers.push(craft_dxf::model::Layer { + handle: None, + name: layer.dxf_name.clone(), + color: craft_dxf::model::Color::Index(layer.dxf_color_index), + on: layer.visible, + frozen: false, + vp_freeze_new: false, + locked: false, + plot: true, + linetype: "Continuous".into(), + lineweight: craft_dxf::model::Lineweight::Default, + plot_style: None, + transparency: craft_dxf::model::Transparency::ByLayer, + description: None, + }); + } + for output in &scene.paths { + let Some(layer) = scene.layers.get(output.layer) else { + return Err(Error::InvalidGeometry); + }; + let view_scale = (scene.width_mm / scene.view_box[2]).min(scene.height_mm / scene.view_box[3]); + let offset_x = (scene.width_mm - scene.view_box[2] * view_scale) / 2.0; + let offset_y = (scene.height_mm - scene.view_box[3] * view_scale) / 2.0; + let mut page_path = output.path.clone(); + page_path.apply_affine(kurbo::Affine::new([ + view_scale, + 0.0, + 0.0, + view_scale, + offset_x - scene.view_box[0] * view_scale, + offset_y - scene.view_box[1] * view_scale, + ])); + for (points, closed) in flatten(&page_path)? { + if points.len() < 2 { + continue; + } + let mut vertices = Vec::with_capacity(points.len()); + for point in points { + if !(point.x.is_finite() && point.y.is_finite()) { + return Err(Error::InvalidGeometry); + } + vertices.push(craft_dxf::model::LwVertex { x: point.x, y: scene.height_mm - point.y, ..Default::default() }); + } + drawing.entities.push(craft_dxf::model::Entity { + common: craft_dxf::model::Common { + handle: None, + owner: None, + layer: layer.dxf_name.clone(), + linetype: None, + color: craft_dxf::model::Color::ByLayer, + lineweight: craft_dxf::model::Lineweight::ByLayer, + ltscale: 1.0, + visible: true, + paper_space: false, + transparency: craft_dxf::model::Transparency::ByLayer, + thickness: 0.0, + extrusion: [0.0, 0.0, 1.0], + plot_style: None, + }, + kind: craft_dxf::model::EntityKind::LwPolyline(craft_dxf::model::LwPolyline { + vertices, + closed: closed || output.close_open_contours, + plinegen: false, + const_width: 0.0, + elevation: 0.0, + }), + }); + } + } + let (tags, skipped) = drawing.tags(); + if skipped > 0 { + return Err(Error::Write(format!("DXF writer skipped {skipped} entities"))); + } + Ok(craft_dxf::write_ascii(&tags)) +} + +fn flatten(path: &BezPath) -> Result, bool)>, Error> { + const MAX_POINTS: usize = 2_000_000; + let mut contours: Vec<(Vec, bool)> = Vec::new(); + let mut count = 0usize; + vectorcraft_geom::flatten(path.iter(), 0.05, |element| { + count = count.saturating_add(1); + if count > MAX_POINTS { + return; + } + match element { + PathEl::MoveTo(point) => contours.push((vec![point], false)), + PathEl::LineTo(point) => { + if let Some((points, _)) = contours.last_mut() { + points.push(point); + } + } + PathEl::ClosePath => { + if let Some((_, closed)) = contours.last_mut() { + *closed = true; + } + } + PathEl::QuadTo(..) | PathEl::CurveTo(..) => {} + } + }); + if count > MAX_POINTS { + return Err(Error::Write("path exceeds the DXF flattening limit".into())); + } + Ok(contours) +} + +fn validate_scene(scene: &OutputScene) -> Result<(), Error> { + if !(scene.width_mm.is_finite() && scene.height_mm.is_finite() && scene.width_mm > 0.0 && scene.height_mm > 0.0) { + return Err(Error::InvalidGeometry); + } + if !(scene.view_box.iter().all(|value| value.is_finite()) && scene.view_box[2] > 0.0 && scene.view_box[3] > 0.0) + || scene.paths.len() > crate::MAX_SHAPES + || scene.paths.iter().any(|path| { + path.layer >= scene.layers.len() + || !(path.stroke_width_mm.is_finite() && path.stroke_width_mm > 0.0) + || path.path.elements().iter().any(|element| match *element { + PathEl::MoveTo(point) | PathEl::LineTo(point) => !point_is_finite(point), + PathEl::QuadTo(a, b) => !point_is_finite(a) || !point_is_finite(b), + PathEl::CurveTo(a, b, c) => !point_is_finite(a) || !point_is_finite(b) || !point_is_finite(c), + PathEl::ClosePath => false, + }) + }) + { + return Err(Error::InvalidGeometry); + } + Ok(()) +} + +fn point_is_finite(point: Point) -> bool { + point.x.is_finite() && point.y.is_finite() +} + +fn format_float(value: f64) -> String { + value.to_string() +} diff --git a/crates/pdf/src/paths.rs b/crates/pdf/src/paths.rs index bbb93942e..0ed5dce8b 100644 --- a/crates/pdf/src/paths.rs +++ b/crates/pdf/src/paths.rs @@ -145,6 +145,7 @@ fn import_selected(bytes: &[u8], limits: Limits, selected: Option) -> Res #[derive(Default)] struct Collector { paths: Vec, + last_fill_path: Option, omitted_text: usize, omitted_images: usize, clips_seen: usize, @@ -157,26 +158,37 @@ struct Collector { 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) { + fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, mode: &PathDrawMode) { if path.is_empty() { return; } + let path = transform * path.clone(); + match mode { + PathDrawMode::Fill(_) => self.last_fill_path = Some(path.clone()), + PathDrawMode::Stroke(_) => { + if self.last_fill_path.take().as_ref() == Some(&path) { + 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()); + 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.last_fill_path = None; self.omitted_text = self.omitted_text.saturating_add(1); } fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) { + self.last_fill_path = None; self.omitted_images = self.omitted_images.saturating_add(1); } fn pop_clip_path(&mut self) {} diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 49b22e213..e296fe625 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -17,6 +17,8 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] kurbo = { workspace = true } roxmltree = "0.21.2" thiserror = { workspace = true } +vectorcraft-geom = { workspace = true } +vectorcraft-pathops = { workspace = true } # The full SVG parser (Apache-2.0 OR MIT). The one version every Craft app uses: apps depend on # craft-svg's `tree` feature and take usvg from its re-export, never directly. usvg = { version = "=0.48.1", default-features = false, optional = true } diff --git a/crates/svg/src/paths.rs b/crates/svg/src/paths.rs index e909283fd..504a1dd1b 100644 --- a/crates/svg/src/paths.rs +++ b/crates/svg/src/paths.rs @@ -6,12 +6,11 @@ //! millimetres, with its stroke paint and the clip regions that apply to it. The caller tags //! shapes through [`Kind`] (cut or engrave, layer, pen…) from each element's computed style and //! class. Input is treated as hostile: size, node, depth and instance limits, and cyclic -//! references, are errors. -//! -//! Geometry only. Boolean operations (applying [`Clip`]s) are left to the caller. Stroke paint -//! is returned as written; [`crate::color::parse_color`] reads it as a CSS colour. +//! references, are errors. [`Imported::clipped_path`] applies the collected clip regions with +//! their SVG fill rules. Stroke paint is returned as written; [`crate::color::parse_color`] +//! reads it as a CSS colour. -use kurbo::BezPath; +use kurbo::{BezPath, PathEl, Point, Shape as _}; /// Why an SVG could not be read; the message starts with the name given to [`import`]. #[derive(Debug, thiserror::Error)] @@ -60,7 +59,7 @@ pub struct Clip { pub pieces: Vec<(BezPath, FillRule)>, } -/// One drawable element, transformed into the document's millimetre space and not yet clipped. +/// One drawable element, transformed into the document's millimetre space. #[derive(Debug, Clone, PartialEq)] pub struct Shape { pub path: BezPath, @@ -70,6 +69,12 @@ pub struct Shape { /// The stroke colour as written (`currentColor` and paint-server fallbacks resolved), or /// `None` without a stroke. pub stroke: Option, + /// Whether the computed style paints the path interior (`fill` defaults to black). + pub has_fill: bool, + /// Whether the computed style paints a stroke. + pub has_stroke: bool, + /// The computed `fill-rule`, defaulting to SVG's `nonzero` rule. + pub fill_rule: FillRule, /// The regions the shape is clipped by, as indices into [`Imported::clips`], outermost first. pub clips: Vec, /// The name of the innermost Inkscape layer the element is in: a `` with @@ -96,6 +101,275 @@ pub struct Imported { pub dropped_warnings: usize, } +impl Imported { + /// Return one imported shape with every applicable SVG `clip-path` applied. + /// + /// Clip geometry is evaluated here so applications can map the resulting path into their own + /// document model without reimplementing SVG fill rules or boolean clipping. + pub fn clipped_path(&self, shape: &Shape) -> Result { + let mut path = shape.path.clone(); + let mut work_left = MAX_CLIP_WORK; + let mut output_left = MAX_CLIPPED_POINTS; + for &clip_index in &shape.clips { + let clip = self.clips.get(clip_index).ok_or_else(|| Error("SVG shape refers to a missing clip region".into()))?; + path = apply_clip(&path, clip, shape.has_fill, shape.has_stroke, shape.fill_rule, &mut work_left, &mut output_left)?; + } + Ok(path) + } +} + +fn apply_clip( + path: &BezPath, + clip: &Clip, + has_fill: bool, + has_stroke: bool, + fill_rule: FillRule, + work_left: &mut usize, + output_left: &mut usize, +) -> Result { + if clip.pieces.is_empty() { + return Ok(BezPath::new()); + } + let pieces: Vec<_> = clip + .pieces + .iter() + .map(|(path, rule)| { + ( + vectorcraft_geom::PathData::from_bezpath(path), + match rule { + FillRule::EvenOdd => vectorcraft_geom::FillRule::EvenOdd, + FillRule::NonZero => vectorcraft_geom::FillRule::NonZero, + }, + ) + }) + .collect(); + let inputs: Vec<_> = pieces.iter().map(|(path, rule)| (path, *rule)).collect(); + let region = vectorcraft_pathops::try_boolean_n(&inputs, |inside: &[bool]| inside.iter().any(|v| *v)) + .map_err(|e| Error(format!("SVG clip-path: {e}")))? + .to_bezpath(); + clip_path_to_region(path, ®ion, has_fill, has_stroke, fill_rule, work_left, output_left).map_err(|e| Error(format!("SVG clip-path: {e}"))) +} + +fn clip_path_to_region( + path: &BezPath, + region: &BezPath, + has_fill: bool, + has_stroke: bool, + fill_rule: FillRule, + work_left: &mut usize, + output_left: &mut usize, +) -> Result { + if region.elements().is_empty() { + return Ok(BezPath::new()); + } + let mut out = BezPath::new(); + if has_fill { + let kept = vectorcraft_pathops::try_boolean( + &vectorcraft_geom::PathData::from_bezpath(path), + match fill_rule { + FillRule::EvenOdd => vectorcraft_geom::FillRule::EvenOdd, + FillRule::NonZero => vectorcraft_geom::FillRule::NonZero, + }, + &vectorcraft_geom::PathData::from_bezpath(region), + vectorcraft_geom::FillRule::NonZero, + vectorcraft_pathops::BoolOp::Intersect, + 0.001, + ) + .map_err(|e| e.to_string())?; + out.extend(kept.to_bezpath()); + } + // Some callers deliberately retain geometry even when the source has no paint. Keep that + // geometry clip-able as linework instead of silently deleting it. + if has_stroke || (!has_fill && !has_stroke) { + let polygons: Vec> = + flatten_contours(region)?.into_iter().filter(|(points, closed)| *closed && points.len() >= 3).map(|(points, _)| points).collect(); + for (mut points, closed) in flatten_contours(path)? { + if closed + && points.len() > 1 + && points.first().zip(points.last()).is_some_and(|(first, last)| first.distance(*last) > 1e-9) + && let Some(first) = points.first().copied() + { + points.push(first); + } + for piece in clip_polyline(&points, &polygons, work_left, output_left)? { + let Some((&first, rest)) = piece.split_first() else { + continue; + }; + out.move_to(first); + for &point in rest { + out.line_to(point); + } + } + } + } + Ok(out) +} + +const MAX_CLIPPED_POINTS: usize = 500_000; +const MAX_CLIP_WORK: usize = 5_000_000; + +fn flatten_contours(path: &BezPath) -> Result, bool)>, String> { + flatten_contours_with_limits(path, MAX_SVG_SEGMENTS, MAX_CLIPPED_POINTS) +} + +fn flatten_contours_with_limits(path: &BezPath, max_segments: usize, max_points: usize) -> Result, bool)>, String> { + if path.elements().len() > max_segments { + return Err("path exceeds the SVG segment limit".into()); + } + let bounds = path.bounding_box(); + let tolerance = (bounds.width().max(bounds.height()) * 2e-4).max(1e-3); + let mut contours: Vec<(Vec, bool)> = Vec::new(); + let mut total = 0usize; + vectorcraft_geom::flatten(path.iter(), tolerance, |el| { + total = total.saturating_add(1); + if total > max_points { + return; + } + match el { + PathEl::MoveTo(point) => contours.push((vec![point], false)), + PathEl::LineTo(point) => { + if let Some((points, _)) = contours.last_mut() { + points.push(point); + } + } + PathEl::ClosePath => { + let start = if let Some((points, closed)) = contours.last_mut() { + if let Some(first) = points.first().copied() + && points.last().is_some_and(|last| last.distance(first) > 1e-9) + { + points.push(first); + } + *closed = true; + points.first().copied() + } else { + None + }; + if let Some(start) = start { + contours.push((vec![start], false)); + } + } + PathEl::QuadTo(..) | PathEl::CurveTo(..) => {} + } + }); + if total > max_points { + return Err(format!("path is too detailed (over {max_points} points)")); + } + for (points, _) in &mut contours { + points.dedup_by(|a, b| a.distance(*b) < 1e-9); + } + contours.retain(|(points, _)| points.len() > 1); + Ok(contours) +} + +fn clip_polyline(points: &[Point], polygons: &[Vec], work_left: &mut usize, output_left: &mut usize) -> Result>, String> { + let mut out: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut ts = Vec::new(); + for segment in points.windows(2) { + let (a, b) = (segment[0], segment[1]); + ts.clear(); + ts.push(0.0); + crossings(polygons, a, b, &mut ts, work_left)?; + ts.push(1.0); + ts.sort_by(f64::total_cmp); + for pair in ts.windows(2) { + let (t0, t1) = (pair[0], pair[1]); + if t1 - t0 < 1e-12 { + continue; + } + if inside_even_odd(polygons, a.lerp(b, (t0 + t1) / 2.0), work_left)? { + let (p0, p1) = (a.lerp(b, t0), a.lerp(b, t1)); + if current.last().is_none_or(|last| last.distance(p0) > 1e-9) { + if current.len() > 1 { + out.push(std::mem::take(&mut current)); + } + current.clear(); + push_clip_point(&mut current, p0, output_left)?; + } + push_clip_point(&mut current, p1, output_left)?; + } else if current.len() > 1 { + out.push(std::mem::take(&mut current)); + } else { + current.clear(); + } + } + } + if current.len() > 1 { + out.push(current); + } + Ok(out) +} + +fn crossings(polygons: &[Vec], a: Point, b: Point, out: &mut Vec, work_left: &mut usize) -> Result<(), String> { + let direction = b - a; + for polygon in polygons { + for index in 0..polygon.len() { + charge_clip_work(work_left)?; + let (Some(p), Some(q)) = (polygon.get(index), polygon.get((index + 1) % polygon.len())) else { + continue; + }; + let edge = *q - *p; + let denominator = direction.cross(edge); + if denominator.abs() < 1e-15 { + continue; + } + let offset = *p - a; + let t = offset.cross(edge) / denominator; + let u = offset.cross(direction) / denominator; + if t > 0.0 && t < 1.0 && (0.0..1.0).contains(&u) { + out.push(t); + } + } + } + Ok(()) +} + +fn inside_even_odd(polygons: &[Vec], point: Point, work_left: &mut usize) -> Result { + let mut odd = false; + for polygon in polygons { + for index in 0..polygon.len() { + charge_clip_work(work_left)?; + let (Some(a), Some(b)) = (polygon.get(index), polygon.get((index + 1) % polygon.len())) else { + continue; + }; + let edge = *b - *a; + let relative = point - *a; + let length_squared = edge.dot(edge); + let edge_length = length_squared.sqrt(); + if edge_length <= f64::EPSILON { + if relative.x.hypot(relative.y) <= 1e-9 { + return Ok(true); + } + continue; + } + let tolerance = 1e-9 * edge_length.max(1.0); + let projection = relative.dot(edge); + let projection_tolerance = tolerance * edge_length; + if edge.cross(relative).abs() <= projection_tolerance + && projection >= -projection_tolerance + && projection <= length_squared + projection_tolerance + { + return Ok(true); + } + if (a.y > point.y) != (b.y > point.y) && point.x < a.x + (point.y - a.y) / (b.y - a.y) * (b.x - a.x) { + odd = !odd; + } + } + } + Ok(odd) +} + +fn charge_clip_work(work_left: &mut usize) -> Result<(), String> { + *work_left = work_left.checked_sub(1).ok_or_else(|| "clip-path work limit exceeded".to_string())?; + Ok(()) +} + +fn push_clip_point(points: &mut Vec, point: Point, output_left: &mut usize) -> Result<(), String> { + *output_left = output_left.checked_sub(1).ok_or_else(|| "clip-path output point limit exceeded".to_string())?; + points.push(point); + Ok(()) +} + pub const MAX_SVG_BYTES: usize = 16 * 1024 * 1024; pub const MAX_SVG_NODES: usize = 100_000; pub const MAX_SVG_DEPTH: usize = 128; @@ -151,7 +425,7 @@ pub(crate) const MAX_CSS_SELECTOR_CHECKS: usize = 5_000_000; /// presentation-property cascade. Curves stay curves: nothing is flattened. /// /// Each shape carries its stroke paint as written and the `clip-path` regions that apply to it; -/// parsing the colour and cutting the shape down to its clips are the caller's. +/// [`Imported::clipped_path`] returns its geometry with those clips applied. /// /// What cannot be represented is skipped with a line in [`Imported::warnings`]: `` (no /// fonts here), masks and filters (the art comes unmasked and unfiltered), ignored CSS at-rules @@ -420,6 +694,9 @@ fn import_node<'a, 'input, K: Kind>( kind: state.kind.clone(), label: label(node), stroke: stroke_paint(&style), + has_fill: style.get("fill").is_none_or(|value| value != "none"), + has_stroke: style.get("stroke").is_some_and(|value| value != "none"), + fill_rule: if style.get("fill-rule").is_some_and(|value| value == "evenodd") { FillRule::EvenOdd } else { FillRule::NonZero }, clips: state.clips.clone(), layer: state.layer.as_deref().map(str::to_string), }); @@ -854,7 +1131,8 @@ fn ellipse_path(cx: f64, cy: f64, rx: f64, ry: f64) -> BezPath { pub const MAX_CSS_BYTES: usize = 1024 * 1024; const MAX_CSS_RULES: usize = 10_000; const MAX_CSS_PROPERTIES: usize = 32; -const SUPPORTED_STYLE_PROPERTIES: &[&str] = &["display", "visibility", "fill", "stroke", "stroke-width", "color", "clip-path", "mask", "filter"]; +const SUPPORTED_STYLE_PROPERTIES: &[&str] = + &["display", "visibility", "fill", "fill-rule", "stroke", "stroke-width", "color", "clip-path", "mask", "filter"]; /// Whether the cascade tracks `property`: the properties above and [`Kind::PROPERTIES`]. fn supported(extra: &[&str], property: &str) -> bool { @@ -1644,7 +1922,6 @@ fn svg_error(path_name: &str, message: &str) -> Error { #[cfg(test)] mod tests { use super::*; - use kurbo::Shape as _; /// A cutter's two kinds, set by `data-kind` or by class. #[derive(Debug, Clone, Copy, Default, PartialEq)] @@ -1967,6 +2244,64 @@ mod tests { assert!(got.shapes[4].clips.is_empty()); } + #[test] + fn clipped_paths_apply_piece_unions_fill_rules_and_nested_regions() { + let got = read( + r##" + + + + + + + + + + + + + + + "##, + ); + + let clipped = |label: &str| { + let shape = got.shapes.iter().find(|shape| shape.label == label).unwrap(); + got.clipped_path(shape).unwrap() + }; + let union = clipped(r#""#); + assert_eq!(union.bounding_box(), kurbo::Rect::new(0.0, 0.0, 15.0, 10.0)); + assert_eq!(union.elements().iter().filter(|el| matches!(el, PathEl::ClosePath)).count(), 2); + + let ring = clipped(r#""#); + assert_eq!(ring.bounding_box(), kurbo::Rect::new(0.0, 0.0, 20.0, 20.0)); + assert_eq!(ring.elements().iter().filter(|el| matches!(el, PathEl::ClosePath)).count(), 2); + + let nested = clipped(r#""#); + assert!(near(nested.bounding_box().x0, 10.0) && near(nested.bounding_box().x1, 15.0)); + let open = clipped(r#""#); + assert!(near(open.bounding_box().x0, 0.0) && near(open.bounding_box().x1, 15.0)); + let boundary = clipped(r#""#); + assert!(near(boundary.bounding_box().x0, 15.0) && near(boundary.bounding_box().x1, 15.0)); + let unpainted = clipped(r#""#); + assert!(near(unpainted.bounding_box().x0, 0.0) && near(unpainted.bounding_box().x1, 15.0)); + assert!(clipped(r#""#).elements().is_empty()); + + let mut invalid = got.clone(); + invalid.shapes[0].clips.push(usize::MAX); + assert!(invalid.clipped_path(&invalid.shapes[0]).is_err()); + } + + #[test] + fn clipped_path_flattening_enforces_segment_and_point_bounds() { + let mut path = kurbo::BezPath::new(); + path.move_to(kurbo::Point::ZERO); + path.line_to(kurbo::Point::new(1.0, 0.0)); + path.line_to(kurbo::Point::new(2.0, 0.0)); + assert!(flatten_contours_with_limits(&path, 2, 100).is_err()); + assert!(flatten_contours_with_limits(&path, 100, 2).is_err()); + } + #[test] fn clip_paths_that_cannot_be_applied_warn_and_leave_the_shape_unclipped() { for (svg, needle) in [