diff --git a/crates/file-import/src/lib.rs b/crates/file-import/src/lib.rs index 3b8feeabc..2090b7531 100644 --- a/crates/file-import/src/lib.rs +++ b/crates/file-import/src/lib.rs @@ -297,7 +297,7 @@ fn import_dxf(bytes: &[u8], format: ImportFormat, limits: Limits) -> Result ( (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 Result { - 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 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 elements = scene .layers .iter() @@ -116,7 +120,7 @@ fn write_svg(scene: &OutputScene) -> Result { 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 { .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: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, 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, 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 { 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() +} diff --git a/crates/svg/src/paths.rs b/crates/svg/src/paths.rs index ecace1fca..9506a7b6f 100644 --- a/crates/svg/src/paths.rs +++ b/crates/svg/src/paths.rs @@ -69,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 @@ -102,15 +108,25 @@ impl Imported { /// 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)?; + 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 { +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()); } @@ -131,28 +147,29 @@ fn apply_clip(path: &BezPath, clip: &Clip) -> Result { 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).map_err(|e| Error(format!("SVG clip-path: {e}"))) + 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) -> Result { +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 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> = 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 Result, 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]) -> Vec> { +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(); @@ -234,7 +267,7 @@ fn clip_polyline(points: &[Point], polygons: &[Vec]) -> Vec> { 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]) -> Vec> { 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]) -> Vec> { if current.len() > 1 { out.push(current); } - out + Ok(out) } -fn crossings(polygons: &[Vec], a: Point, b: Point, out: &mut Vec) { +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; }; @@ -285,12 +319,14 @@ fn crossings(polygons: &[Vec], a: Point, b: Point, out: &mut Vec) { } } } + Ok(()) } -fn inside_even_odd(polygons: &[Vec], point: Point) -> bool { +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; }; @@ -299,7 +335,18 @@ fn inside_even_odd(polygons: &[Vec], 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, 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 {