Merge pull request #1186 from dylanmtaylor/codex/stencilcraft-file-import-registry

Add shared neutral file import and writer registry
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 09:04:29 -05:00
commit 8ebdc499a5
9 files changed
+1262 -14

No files matched your search

Generated
+17
View File
@@ -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]]
+3
View File
@@ -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" }
+8 -3
View File
@@ -47,12 +47,16 @@ pub fn import(bytes: &[u8], limits: Limits) -> Result<Imported, Error> {
}
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<BezPath>,
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;
+22
View File
@@ -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
+405
View File
@@ -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<String>,
pub class: Option<String>,
pub stroke: Option<String>,
/// Parsed RGB stroke color when the source stroke is a concrete CSS color.
pub stroke_rgb: Option<[u8; 3]>,
pub layer: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VectorScene {
pub format: ImportFormat,
pub width_mm: f64,
pub height_mm: f64,
pub shapes: Vec<SceneShape>,
pub warnings: Vec<String>,
/// 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<u8>,
}
#[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<craft_pdf::write::WriteError> 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<ImportFormat> {
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<Imported, Error> {
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<String>,
class: Option<String>,
}
impl craft_svg::paths::Kind for SvgTag {
const PROPERTIES: &'static [&'static str] = &["data-kind"];
fn of(style: &craft_svg::paths::StyleMap, class: &str) -> Option<Self> {
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<Imported, Error> {
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::<SvgTag>(&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<Imported, Error> {
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<Imported, Error> {
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<Imported, Error> {
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<Imported, Error> {
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));
}
}
+447
View File
@@ -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<String>,
pub data_kind: Option<String>,
}
#[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<String>,
pub data_kind: Option<String>,
/// 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<OutputLayer>,
pub paths: Vec<OutputPath>,
}
/// 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<PdfPath>,
}
/// 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<f64>,
}
/// Match a target extension to the centralized writer registry.
pub fn writer_format_for_extension(extension: &str) -> Option<WriteFormat> {
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<ImportFormat> {
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<Vec<u8>, 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<Vec<u8>, 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<String, Error> {
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::<Vec<_>>();
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<Vec<u8>, 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<Vec<&OutputPath>> {
let mut grouped: Vec<Vec<&OutputPath>> = (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<Vec<u8>, 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<String, Error> {
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<Vec<(Vec<Point>, bool)>, Error> {
const MAX_POINTS: usize = 2_000_000;
let mut contours: Vec<(Vec<Point>, 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()
}
+14 -2
View File
@@ -145,6 +145,7 @@ fn import_selected(bytes: &[u8], limits: Limits, selected: Option<usize>) -> Res
#[derive(Default)]
struct Collector {
paths: Vec<BezPath>,
last_fill_path: Option<BezPath>,
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<SoftMask<'_>>) {}
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<SoftMask<'_>>, _: 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) {}
+2
View File
@@ -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 }
+344 -9
View File
@@ -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<K> {
pub path: BezPath,
@@ -70,6 +69,12 @@ pub struct Shape<K> {
/// The stroke colour as written (`currentColor` and paint-server fallbacks resolved), or
/// `None` without a stroke.
pub stroke: Option<String>,
/// 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<usize>,
/// The name of the innermost Inkscape layer the element is in: a `<g>` with
@@ -96,6 +101,275 @@ pub struct Imported<K> {
pub dropped_warnings: usize,
}
impl<K> Imported<K> {
/// 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<K>) -> Result<BezPath, Error> {
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<BezPath, Error> {
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, &region, 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<BezPath, String> {
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<Vec<Point>> =
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<Vec<(Vec<Point>, 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<Vec<(Vec<Point>, 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<Point>, 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<Point>], work_left: &mut usize, output_left: &mut usize) -> Result<Vec<Vec<Point>>, String> {
let mut out: Vec<Vec<Point>> = Vec::new();
let mut current: Vec<Point> = 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<Point>], a: Point, b: Point, out: &mut Vec<f64>, 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: Point, work_left: &mut usize) -> Result<bool, String> {
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: 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`]: `<text>` (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##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<clipPath id="pieces"><rect width="5" height="10"/><rect x="10" width="5" height="10"/></clipPath>
<clipPath id="ring"><path d="M0 0H20V20H0Z M5 5H15V15H5Z" clip-rule="evenodd"/></clipPath>
<clipPath id="left"><rect width="15" height="10"/></clipPath>
<clipPath id="right"><rect x="10" width="10" height="10"/></clipPath>
<clipPath id="empty"/>
</defs>
<rect id="piece-union" width="20" height="10" clip-path="url(#pieces)"/>
<rect id="evenodd-ring" width="20" height="20" clip-path="url(#ring)"/>
<g clip-path="url(#left)"><line id="nested-open" x1="0" y1="5" x2="20" y2="5" stroke="black" clip-path="url(#right)"/></g>
<line id="open" x1="0" y1="5" x2="20" y2="5" stroke="black" clip-path="url(#left)"/>
<line id="boundary" x1="15" y1="2" x2="15" y2="8" stroke="black" clip-path="url(#pieces)"/>
<path id="unpainted" d="M0 5H20" data-kind="engrave" fill="none" stroke="none" clip-path="url(#left)"/>
<rect id="empty-result" width="20" height="10" clip-path="url(#empty)"/>
</svg>"##,
);
let clipped = |label: &str| {
let shape = got.shapes.iter().find(|shape| shape.label == label).unwrap();
got.clipped_path(shape).unwrap()
};
let union = clipped(r#"<rect id="piece-union">"#);
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#"<rect id="evenodd-ring">"#);
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#"<line id="nested-open">"#);
assert!(near(nested.bounding_box().x0, 10.0) && near(nested.bounding_box().x1, 15.0));
let open = clipped(r#"<line id="open">"#);
assert!(near(open.bounding_box().x0, 0.0) && near(open.bounding_box().x1, 15.0));
let boundary = clipped(r#"<line id="boundary">"#);
assert!(near(boundary.bounding_box().x0, 15.0) && near(boundary.bounding_box().x1, 15.0));
let unpainted = clipped(r#"<path id="unpainted">"#);
assert!(near(unpainted.bounding_box().x0, 0.0) && near(unpainted.bounding_box().x1, 15.0));
assert!(clipped(r#"<rect id="empty-result">"#).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 [