Harden neutral vector import and shared output

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 07:36:18 -05:00
1 parent 62e2328905
commit 2a8bb6a4e3
3 files changed
+118 -48

No files matched your search

+5 -9
View File
@@ -297,7 +297,7 @@ fn import_dxf(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result<Impo
Some(bounds) => (
(bounds.width() * imported.unit_to_mm).max(1.0),
(bounds.height() * imported.unit_to_mm).max(1.0),
Affine::scale(imported.unit_to_mm) * Affine::translate(-bounds.origin().to_vec2()),
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),
};
@@ -310,26 +310,22 @@ fn import_dxf(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result<Impo
}
fn import_pdf(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result<Imported, Error> {
let imported = craft_pdf::paths::import(
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_count = imported.pages.len();
let page = imported.pages.into_iter().next().ok_or_else(|| Error::Decode("PDF has no pages".into()))?;
let bounds = page.crop_box;
let transform = Affine::scale(MM_PER_PT) * Affine::new([1.0, 0.0, 0.0, -1.0, -bounds.x0, bounds.y1]);
let mut warnings = imported.warnings;
if page_count > 1 {
warnings.push("only the first PDF page was imported".into());
}
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: transform * path, tag: None, class: None, stroke: None, stroke_rgb: None, layer: None })
.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,
@@ -348,7 +344,7 @@ fn import_eps(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result<Impo
)
.map_err(|error| Error::Decode(error.to_string()))?;
let bounds = imported.page_bounds;
let transform = Affine::scale(MM_PER_PT) * Affine::translate(-bounds.origin().to_vec2());
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());
+28 -5
View File
@@ -102,6 +102,10 @@ pub fn write_raster(extension: &str, width: u32, height: u32, rgba: &[u8], jpeg_
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 elements = scene
.layers
.iter()
@@ -116,7 +120,7 @@ fn write_svg(scene: &OutputScene) -> Result<String, Error> {
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::millimetres("stroke-width", path.stroke_width_mm),
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()));
@@ -129,7 +133,7 @@ fn write_svg(scene: &OutputScene) -> Result<String, Error> {
.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:visible" } else { "display:hidden" }),
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()));
@@ -160,6 +164,9 @@ fn write_pdf(scene: &OutputScene) -> Result<Vec<u8>, Error> {
.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 = (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;
for source in &scene.paths {
if !scene.layers.get(source.layer).is_some_and(|layer| layer.visible) {
continue;
@@ -167,8 +174,8 @@ fn write_pdf(scene: &OutputScene) -> Result<Vec<u8>, Error> {
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 * PT_PER_MM;
let y = p.y * PT_PER_MM;
let x = ((p.x - scene.view_box[0]) * view_scale + offset_x) * PT_PER_MM;
let y = ((p.y - scene.view_box[1]) * view_scale + offset_y) * PT_PER_MM;
if !(p.x.is_finite()
&& p.y.is_finite()
&& x.is_finite()
@@ -252,7 +259,19 @@ fn write_dxf(scene: &OutputScene) -> Result<String, Error> {
let Some(layer) = scene.layers.get(output.layer) else {
return Err(Error::InvalidGeometry);
};
for (points, closed) in flatten(&output.path)? {
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;
}
@@ -351,3 +370,7 @@ fn validate_scene(scene: &OutputScene) -> Result<(), Error> {
fn point_is_finite(point: Point) -> bool {
point.x.is_finite() && point.y.is_finite()
}
fn format_float(value: f64) -> String {
value.to_string()
}
+85 -34
View File
@@ -69,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
@@ -102,15 +108,25 @@ impl<K> Imported<K> {
/// 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)?;
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) -> Result<BezPath, Error> {
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());
}
@@ -131,28 +147,29 @@ fn apply_clip(path: &BezPath, clip: &Clip) -> Result<BezPath, Error> {
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).map_err(|e| Error(format!("SVG clip-path: {e}")))
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) -> Result<BezPath, String> {
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 closed, mut open) = (BezPath::new(), BezPath::new());
let mut contour = Vec::new();
for el in path.elements().iter().copied().chain([PathEl::MoveTo(Point::ZERO)]) {
if matches!(el, PathEl::MoveTo(_)) && !contour.is_empty() {
let target = if matches!(contour.last(), Some(PathEl::ClosePath)) { &mut closed } else { &mut open };
target.extend(contour.drain(..));
}
contour.push(el);
}
let mut out = BezPath::new();
if !closed.elements().is_empty() {
if has_fill {
let kept = vectorcraft_pathops::try_boolean(
&vectorcraft_geom::PathData::from_bezpath(&closed),
vectorcraft_geom::FillRule::EvenOdd,
&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,
@@ -161,11 +178,18 @@ fn clip_path_to_region(path: &BezPath, region: &BezPath) -> Result<BezPath, Stri
.map_err(|e| e.to_string())?;
out.extend(kept.to_bezpath());
}
if !open.elements().is_empty() {
if has_stroke {
let polygons: Vec<Vec<Point>> =
flatten_contours(region)?.into_iter().filter(|(points, closed)| *closed && points.len() >= 3).map(|(points, _)| points).collect();
for (points, _) in flatten_contours(&open)? {
for piece in clip_polyline(&points, &polygons) {
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;
};
@@ -180,6 +204,7 @@ fn clip_path_to_region(path: &BezPath, region: &BezPath) -> Result<BezPath, Stri
}
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)
@@ -206,8 +231,19 @@ fn flatten_contours_with_limits(path: &BezPath, max_segments: usize, max_points:
}
}
PathEl::ClosePath => {
if let Some((_, closed)) = contours.last_mut() {
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(..) => {}
@@ -218,15 +254,12 @@ fn flatten_contours_with_limits(path: &BezPath, max_segments: usize, max_points:
}
for (points, closed) in &mut contours {
points.dedup_by(|a, b| a.distance(*b) < 1e-9);
if *closed && points.len() > 1 && points.first().zip(points.last()).is_some_and(|(first, last)| first.distance(*last) < 1e-9) {
points.pop();
}
}
contours.retain(|(points, _)| points.len() > 1);
Ok(contours)
}
fn clip_polyline(points: &[Point], polygons: &[Vec<Point>]) -> Vec<Vec<Point>> {
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();
@@ -234,7 +267,7 @@ fn clip_polyline(points: &[Point], polygons: &[Vec<Point>]) -> Vec<Vec<Point>> {
let (a, b) = (segment[0], segment[1]);
ts.clear();
ts.push(0.0);
crossings(polygons, a, b, &mut ts);
crossings(polygons, a, b, &mut ts, work_left)?;
ts.push(1.0);
ts.sort_by(f64::total_cmp);
for pair in ts.windows(2) {
@@ -242,16 +275,16 @@ fn clip_polyline(points: &[Point], polygons: &[Vec<Point>]) -> Vec<Vec<Point>> {
if t1 - t0 < 1e-12 {
continue;
}
if inside_even_odd(polygons, a.lerp(b, (t0 + t1) / 2.0)) {
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();
current.push(p0);
push_clip_point(&mut current, p0, output_left)?;
}
current.push(p1);
push_clip_point(&mut current, p1, output_left)?;
} else if current.len() > 1 {
out.push(std::mem::take(&mut current));
} else {
@@ -262,13 +295,14 @@ fn clip_polyline(points: &[Point], polygons: &[Vec<Point>]) -> Vec<Vec<Point>> {
if current.len() > 1 {
out.push(current);
}
out
Ok(out)
}
fn crossings(polygons: &[Vec<Point>], a: Point, b: Point, out: &mut Vec<f64>) {
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;
};
@@ -285,12 +319,14 @@ fn crossings(polygons: &[Vec<Point>], a: Point, b: Point, out: &mut Vec<f64>) {
}
}
}
Ok(())
}
fn inside_even_odd(polygons: &[Vec<Point>], point: Point) -> bool {
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;
};
@@ -299,7 +335,18 @@ fn inside_even_odd(polygons: &[Vec<Point>], point: Point) -> bool {
}
}
}
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;
@@ -626,6 +673,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),
});
@@ -1060,7 +1110,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 {