Fix shared file import edge cases

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 07:50:09 -05:00
1 parent 8748f70aea
commit b5dfdb0bda
5 files changed
+67 -8

No files matched your search

+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;
+12
View File
@@ -195,6 +195,7 @@ pub fn format_for(name: &str, bytes: &[u8]) -> Option<ImportFormat> {
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));
}
}
+5 -2
View File
@@ -166,10 +166,13 @@ fn write_svg(scene: &OutputScene) -> Result<String, Error> {
fn write_pdf(scene: &OutputScene) -> Result<Vec<u8>, 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 }])
+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) {}
+28 -1
View File
@@ -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<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)? {
@@ -330,6 +332,25 @@ fn inside_even_odd(polygons: &[Vec<Point>], 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 {
<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>"##,
);
@@ -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#"<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();