This commit is contained in:
Keavon Chambers
2026-07-15 21:54:17 -07:00
parent 751a6ca77d
commit 9537a4ae03
30 changed files with 766 additions and 39 deletions

View File

@@ -83,6 +83,10 @@ pub struct RenderConfig {
pub export_format: ExportFormat,
pub for_export: bool,
pub for_eyedropper: bool,
/// Skip rendering artboard background rectangles, used for pen plotter output.
pub hide_artboard_background: bool,
/// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output.
pub bake_stroke_dashes: bool,
}
impl RenderConfig {

View File

@@ -1,4 +1,5 @@
pub mod convert_usvg_path;
pub mod plot_statistics;
pub mod render_ext;
mod renderer;
pub mod to_peniko;

View File

@@ -0,0 +1,138 @@
use kurbo::{BezPath, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape};
/// Pen movement totals for plotting an SVG document with a pen plotter, which draws every path as its outline.
/// Distances are in SVG user units; scale by the paper fit before converting to wall-clock time.
///
/// Pen-up travel between subpaths is deliberately not measured: the print server reorders paths (and rotates the
/// start points of closed ones) to minimize travel, so document-order travel distance is meaningless. Its cost is
/// captured as part of the constant time per pen lift instead.
pub struct PlotStatistics {
/// Total distance drawn with the pen down, following every path's geometry.
pub pen_down_distance: f64,
/// Number of pen lift/reposition/lower cycles (one per subpath).
pub pen_lift_count: usize,
/// Width of the artwork's bounding box, which the print server scales to fit the paper (the document size is ignored).
pub width: f64,
/// Height of the artwork's bounding box.
pub height: f64,
}
#[derive(Default)]
struct MeasureState {
pen_down_distance: f64,
pen_lift_count: usize,
bounds: Option<Rect>,
}
/// Measures the pen plotter movement statistics of an SVG document by walking every path in document order.
/// Returns `None` if the SVG cannot be parsed or contains no path geometry.
pub fn svg_plot_statistics(svg: &str) -> Option<PlotStatistics> {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let mut state = MeasureState::default();
accumulate_group(tree.root(), &mut state);
let bounds = state.bounds?;
Some(PlotStatistics {
pen_down_distance: state.pen_down_distance,
pen_lift_count: state.pen_lift_count,
width: bounds.width(),
height: bounds.height(),
})
}
fn accumulate_group(group: &usvg::Group, state: &mut MeasureState) {
for node in group.children() {
match node {
usvg::Node::Group(group) => accumulate_group(group, state),
usvg::Node::Path(path) => accumulate_bezpath(&usvg_path_to_bezpath(path), state),
_ => {}
}
}
}
fn accumulate_bezpath(bezpath: &BezPath, state: &mut MeasureState) {
const ARC_LENGTH_ACCURACY: f64 = 0.1;
if bezpath.elements().is_empty() {
return;
}
for segment in bezpath.segments() {
state.pen_down_distance += match segment {
PathSeg::Line(line) => line.p0.distance(line.p1),
segment => segment.arclen(ARC_LENGTH_ACCURACY),
};
}
state.pen_lift_count += bezpath.elements().iter().filter(|element| matches!(element, PathEl::MoveTo(_))).count();
let bounds = bezpath.bounding_box();
if !bounds.is_nan() {
state.bounds = Some(state.bounds.map_or(bounds, |existing| existing.union(bounds)));
}
}
/// Converts a usvg path into a kurbo path with its absolute transform applied.
fn usvg_path_to_bezpath(path: &usvg::Path) -> BezPath {
let transform = path.abs_transform();
let to_point = |point: &usvg::tiny_skia_path::Point| {
let (x, y) = (point.x as f64, point.y as f64);
Point::new(
transform.sx as f64 * x + transform.kx as f64 * y + transform.tx as f64,
transform.ky as f64 * x + transform.sy as f64 * y + transform.ty as f64,
)
};
let mut bezpath = BezPath::new();
let mut points = path.data().points().iter();
for verb in path.data().verbs() {
match verb {
usvg::tiny_skia_path::PathVerb::Move => {
let Some(point) = points.next().map(to_point) else { continue };
bezpath.move_to(point);
}
usvg::tiny_skia_path::PathVerb::Line => {
let Some(end) = points.next().map(to_point) else { continue };
bezpath.line_to(end);
}
usvg::tiny_skia_path::PathVerb::Quad => {
let Some(handle) = points.next().map(to_point) else { continue };
let Some(end) = points.next().map(to_point) else { continue };
bezpath.quad_to(handle, end);
}
usvg::tiny_skia_path::PathVerb::Cubic => {
let Some(first_handle) = points.next().map(to_point) else { continue };
let Some(second_handle) = points.next().map(to_point) else { continue };
let Some(end) = points.next().map(to_point) else { continue };
bezpath.curve_to(first_handle, second_handle, end);
}
usvg::tiny_skia_path::PathVerb::Close => bezpath.close_path(),
}
}
bezpath
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_lines_squares_and_bounds() {
// A 30x30 square (120 units drawn, 1 lift) followed by a vertical line (100 units drawn, 1 lift),
// with an artwork bounding box spanning (10,10) to (40,150)
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 200" width="100" height="200">
<path d="M10,10 L40,10 L40,40 L10,40 Z" fill="black" />
<path d="M10,50 L10,150" stroke="black" fill="none" />
</svg>"##;
let statistics = svg_plot_statistics(svg).unwrap();
assert_eq!(statistics.pen_lift_count, 2);
assert!((statistics.pen_down_distance - 220.).abs() < 1e-6, "pen down was {}", statistics.pen_down_distance);
assert!((statistics.width - 30.).abs() < 1e-6, "width was {}", statistics.width);
assert!((statistics.height - 140.).abs() < 1e-6, "height was {}", statistics.height);
}
}

View File

@@ -179,10 +179,11 @@ impl RenderExt for Stroke {
let default_weight = if self.align != StrokeAlign::Center && render_params.aligned_strokes { 1. / 2. } else { 1. };
// Set to None if the value is the SVG default
// Set to None if the value is the SVG default. When dashes are baked into the path geometry, the dash attributes must be
// omitted so the pattern isn't applied a second time to the already-cut dash segments.
let weight = (self.weight != default_weight).then_some(self.weight);
let dash_array = (!self.dash_lengths.is_empty()).then_some(self.dash_lengths());
let dash_offset = (self.dash_offset != 0.).then_some(self.dash_offset);
let dash_array = (!self.dash_lengths.is_empty() && !render_params.bake_stroke_dashes).then_some(self.dash_lengths());
let dash_offset = (self.dash_offset != 0. && !render_params.bake_stroke_dashes).then_some(self.dash_offset);
let stroke_cap = (self.cap != StrokeCap::Butt).then_some(self.cap);
let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join);
let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit);

View File

@@ -230,6 +230,10 @@ pub struct RenderParams {
pub artboard_background: Option<Color>,
/// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode.
pub viewport_zoom: f64,
/// Skip rendering artboard background rectangles, used for pen plotter output where a background would be traced as a giant filled contour.
pub hide_artboard_background: bool,
/// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output where `stroke-dasharray` would be ignored.
pub bake_stroke_dashes: bool,
}
impl RenderParams {
@@ -718,16 +722,18 @@ impl Render for List<Artboard> {
let height = dimensions.y.abs();
// Background
render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
if background.a() < 1. {
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
}
attributes.push("x", x.to_string());
attributes.push("y", y.to_string());
attributes.push("width", width.to_string());
attributes.push("height", height.to_string());
});
if !render_params.hide_artboard_background {
render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
if background.a() < 1. {
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
}
attributes.push("x", x.to_string());
attributes.push("y", y.to_string());
attributes.push("width", width.to_string());
attributes.push("height", height.to_string());
});
}
// Artwork
render.parent_tag(
@@ -775,10 +781,12 @@ impl Render for List<Artboard> {
let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
let color = SRGBA8::from(background).to_peniko_color();
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer();
if !render_params.hide_artboard_background {
let color = SRGBA8::from(background).to_peniko_color();
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer();
}
if clip {
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect);
@@ -1114,7 +1122,33 @@ impl Render for List<Vector> {
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
if needs_separate_alignment_fill && !wants_stroke_below {
// When baking dashes, cut each stroked path into its visible dash segments so the geometry itself carries the dash
// pattern, letting consumers that ignore `stroke-dasharray` (like a pen plotter) still draw the dashes.
let baked_dash_path = (render_params.bake_stroke_dashes && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()))
.then_some(vector.stroke.as_ref())
.flatten()
.filter(|stroke| stroke.has_renderable_stroke() && !stroke.dash_lengths.is_empty())
.and_then(|stroke| {
let dash_pattern: Vec<f64> = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect();
if dash_pattern.iter().sum::<f64>() <= 0. {
return None;
}
let mut dashed_path = String::new();
for mut bezpath in vector.stroke_bezpath_iter() {
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let dashed_bezpath: BezPath = kurbo::dash(bezpath.iter(), stroke.dash_offset, &dash_pattern).collect();
dashed_path.push_str(dashed_bezpath.to_svg().as_str());
}
Some(dashed_path)
});
// When dashes are baked, the fill is dropped entirely rather than emitted as a separate path: the pen plotter
// draws every path as its outline, and a fill's contour is the same uncut geometry the dashes were cut from,
// so it would be drawn as a solid line right over the dashes.
let emit_separate_fill = needs_separate_alignment_fill && baked_dash_path.is_none();
if emit_separate_fill && !wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
@@ -1142,7 +1176,8 @@ impl Render for List<Vector> {
(id, mask_type, vector_item)
});
if use_face_fill {
// Face fills are dropped when dashes are baked for the same reason as above: their boundaries retrace the dashed edges
if use_face_fill && baked_dash_path.is_none() {
for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) {
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let face_d = face_path.to_svg();
@@ -1161,7 +1196,7 @@ impl Render for List<Vector> {
}
render.leaf_tag("path", |attributes| {
attributes.push("d", path.clone());
attributes.push("d", baked_dash_path.clone().unwrap_or_else(|| path.clone()));
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
@@ -1229,7 +1264,7 @@ impl Render for List<Vector> {
String::new()
};
let fill_attribute = if needs_separate_alignment_fill || use_face_fill {
let fill_attribute = if needs_separate_alignment_fill || use_face_fill || baked_dash_path.is_some() {
r#" fill="none""#.to_string()
} else {
fill_graphic_list
@@ -1261,7 +1296,7 @@ impl Render for List<Vector> {
});
// When splitting passes and stroke is below, draw the fill after the stroke.
if needs_separate_alignment_fill && wants_stroke_below {
if emit_separate_fill && wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
@@ -1473,7 +1508,16 @@ impl Render for List<Vector> {
// Render the path
match render_params.render_mode {
RenderMode::Outline => {
let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params);
let (mut outline_stroke, outline_color_peniko) = get_outline_styles(render_params);
// Show the stroke's dash pattern in the outline preview so dashed strokes read the same as they will be plotted
if let Some(stroke) = stroke.filter(|stroke| stroke.has_renderable_stroke()) {
let dash_pattern: kurbo::Dashes = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect();
if dash_pattern.iter().sum::<f64>() > 0. {
outline_stroke.dash_pattern = dash_pattern;
outline_stroke.dash_offset = stroke.dash_offset;
}
}
scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path);
}