Files
vectorcraft/crates/pdf/src/tests_stroke.rs
T
bflatasticandClaude Opus 5.5 8a837fd4b9 M3.68: groups, layers and type have their own fills, strokes and effects, with Contents and Characters rows
A group's or layer's own fills and strokes paint every member's geometry, below or above the
members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape
the members as one piece and its raster effects apply to the composite (one combined shadow),
respecting knockout groups. Type's own fills and strokes paint under or over its characters.
Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike.

Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids;
appearance.moveItem moves the Contents slot (from: "contents") or an item across it
(contents); appearance.targetContents selects a group's members. Graphic styles applied to a
group now style the group itself. The Appearance panel shows Contents/Characters rows that
drag, and double-click to target the members or edit the characters' paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:59:34 +07:00

95 lines
4.0 KiB
Rust

//! Stroke geometry in PDF export: arrowheads and the trimmed line are written as the shared
//! `vectorcraft_effects::stroke` outlines, and they take the stroke opacity once.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_effects::stroke::{OUTLINE_TOL, line_outline, stroke_pieces};
use vectorcraft_geom::{Point, Rect, Shape, shapes};
use crate::*;
pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) {
let mut d = Document::new(100.0, 100.0);
let mut n = Node::path(
NodeId(0),
shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)),
Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() },
);
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n.clone()).unwrap();
(d, n)
}
fn arrow_stroke(kind: Arrowhead, align: ArrowAlign, opacity: f32) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
st.end_arrow = Some(kind);
st.arrow_align = align;
st.opacity = opacity;
st
}
fn close(a: Rect, b: Rect) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.01)
}
/// Bounding boxes of the filled and the stroked paths of a PDF read back.
pub(crate) fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap();
let (mut fills, mut strokes) = (vec![], vec![]);
back.walk(|m| {
let Some(bb) = m.geometric_bounds().filter(|_| matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })) else { return };
match m.appearance.items.first() {
Some(AppearanceItem::Fill(_)) => fills.push(bb),
Some(AppearanceItem::Stroke(_)) => strokes.push(bb),
None => {}
}
});
(fills, strokes)
}
#[test]
fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for kind in Arrowhead::ALL {
let (d, n) = line_doc(arrow_stroke(kind, align, 1.0));
let bp = n.path_data().unwrap().to_bezpath();
let st = n.appearance.stroke().unwrap();
let pieces = stroke_pieces(&bp, st);
let (fills, strokes) = painted_boxes(&d);
let head = pieces.heads[0].outline.bounding_box();
let line = line_outline(&pieces.line, st, st.width, OUTLINE_TOL).bounding_box();
assert!(strokes.is_empty(), "{kind:?} {align:?}: the line is written as its outline");
assert_eq!(fills.len(), 2, "{kind:?} {align:?}: the line, then the head");
assert!(close(fills[0], line), "{kind:?} {align:?}: {:?} vs {line:?}", fills[0]);
assert!(close(fills[1], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[1]);
if align == ArrowAlign::Tip {
assert!((fills[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head");
}
}
}
}
#[test]
fn line_and_head_share_one_opacity_group() {
let (d, _) = line_doc(arrow_stroke(Arrowhead::Triangle, ArrowAlign::Tip, 0.5));
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let mut group_opacity = None;
let mut leaf_opacities = vec![];
back.walk(|m| match &m.kind {
NodeKind::Group { .. } if m.opacity < 1.0 => group_opacity = Some(m.opacity),
NodeKind::Path { .. } => {
leaf_opacities.push(m.opacity);
for i in &m.appearance.items {
leaf_opacities.push(match i {
AppearanceItem::Fill(f) => f.opacity,
AppearanceItem::Stroke(s) => s.opacity,
});
}
}
_ => {}
});
assert!(group_opacity.is_some_and(|o| (o - 0.5).abs() < 0.01), "{group_opacity:?}");
assert!(leaf_opacities.iter().all(|o| (o - 1.0).abs() < 1e-3), "{leaf_opacities:?}");
}