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/src/lib.rs b/crates/file-import/src/lib.rs index 2090b7531..195363da0 100644 --- a/crates/file-import/src/lib.rs +++ b/crates/file-import/src/lib.rs @@ -195,6 +195,7 @@ pub fn format_for(name: &str, bytes: &[u8]) -> Option { 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, @@ -391,3 +392,14 @@ fn path_is_finite(path: &BezPath) -> bool { } }) } + +#[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 index d7929edf2..2b7754837 100644 --- a/crates/file-import/src/write.rs +++ b/crates/file-import/src/write.rs @@ -166,10 +166,13 @@ fn write_svg(scene: &OutputScene) -> Result { fn write_pdf(scene: &OutputScene) -> Result, Error> { validate_scene(scene)?; + // Match SVG's painter order: layers in order, then paths in source order within each layer. let paths = scene - .paths + .layers .iter() - .filter(|path| scene.layers.get(path.layer).is_some_and(|layer| layer.visible)) + .enumerate() + .filter(|(_, layer)| layer.visible) + .flat_map(|(layer_index, _)| scene.paths.iter().filter(move |path| path.layer == layer_index)) .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 }]) 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/src/paths.rs b/crates/svg/src/paths.rs index 06c390192..504a1dd1b 100644 --- a/crates/svg/src/paths.rs +++ b/crates/svg/src/paths.rs @@ -178,7 +178,9 @@ fn clip_path_to_region( .map_err(|e| e.to_string())?; out.extend(kept.to_bezpath()); } - if has_stroke { + // 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)? { @@ -330,6 +332,25 @@ fn inside_even_odd(polygons: &[Vec], point: Point, work_left: &mut usize) 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; } @@ -2238,6 +2259,8 @@ mod tests { + + "##, ); @@ -2258,6 +2281,10 @@ mod tests { 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();