mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Harden neutral vector import and shared output
This commit is contained in:
3 files changed
+118
-48
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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
@@ -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, ®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<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 {
|
||||
|
||||
Reference in new issue
Block a user