mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
M3.40: stroke on type — the Stroke panel edits the characters' stroke
With type selected, stroke.set (without an item) gives every run the weight, cap, join, miter limit and dashes instead of adding an object stroke; while the Type tool edits text, the Stroke panel styles the selected characters through text.setRangeStyle `strokeOptions`, and the panel reads the first run (or the selection's style). CharStyle gains stroke_cap, stroke_join, stroke_miter_limit and stroke_dash (defaulted, skipped when default). Character strokes and object strokes on type now draw through the shared stroke geometry on the canvas, in PDF and in SVG (live text writes cap/join/miter/dash and tspans reset what the first run sets; object strokes are drawn on the glyph outlines), so every stroke option, opacity and blend applies. Outlines, the Eyedropper and graphic styles keep the options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3a79e6d922
commit
323b178ea7
@@ -652,7 +652,7 @@ impl Renderer {
|
||||
if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) {
|
||||
return;
|
||||
}
|
||||
self.draw_stroke(ctx, f, ig, rule, st, ib);
|
||||
self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, ib);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-32
@@ -17,7 +17,6 @@ pub mod proof;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
|
||||
use vello_cpu::kurbo;
|
||||
@@ -792,7 +791,7 @@ impl Renderer {
|
||||
if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) {
|
||||
continue;
|
||||
}
|
||||
self.draw_stroke(ctx, f, bp, rule, st, bounds);
|
||||
self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -808,7 +807,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||||
/// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its
|
||||
/// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, xf: Affine, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||||
let pieces = effects::stroke::stroke_pieces(bp, st);
|
||||
// The line and its heads overlap: they take the opacity once, in one layer, instead of
|
||||
// each folding it into its paint.
|
||||
@@ -819,10 +821,10 @@ impl Renderer {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_transform(f.view * xf);
|
||||
let closed = effects::stroke::is_closed(bp);
|
||||
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5);
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9));
|
||||
let inside = st.align == StrokeAlign::Inside && closed;
|
||||
if inside {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
@@ -929,7 +931,7 @@ impl Renderer {
|
||||
all
|
||||
});
|
||||
if let Some(all) = &all {
|
||||
self.draw_text_items(ctx, f, below, all, tb);
|
||||
self.draw_text_items(ctx, f, n, below, all, tb);
|
||||
}
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
let Some(path) = g.runs.get(i) else { continue };
|
||||
@@ -943,44 +945,45 @@ impl Renderer {
|
||||
overprint(ctx, run.style.overprint_fill);
|
||||
ctx.fill_path(path);
|
||||
}
|
||||
if !run.style.stroke.is_none()
|
||||
&& run.style.stroke_width > 0.0
|
||||
&& paint::set_paint(ctx, &run.style.stroke, stroke_paint_bounds(g.bounds, run.style.stroke_width), f.doc)
|
||||
{
|
||||
if run.style.has_stroke() {
|
||||
// Character strokes are drawn in text space, with their cap, join and dashes.
|
||||
overprint(ctx, run.style.overprint_stroke);
|
||||
ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width));
|
||||
ctx.stroke_path(path);
|
||||
self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), g.bounds);
|
||||
}
|
||||
}
|
||||
overprint(ctx, false);
|
||||
if let Some(all) = &all {
|
||||
self.draw_text_items(ctx, f, above, all, tb);
|
||||
self.draw_text_items(ctx, f, n, above, all, tb);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document
|
||||
/// space; `tb` their bounds), each with its own opacity and blend mode.
|
||||
fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
|
||||
/// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all`
|
||||
/// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode;
|
||||
/// strokes take every stroke option, as on paths.
|
||||
fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
|
||||
for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
|
||||
let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
match item {
|
||||
AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.fill_path(all);
|
||||
AppearanceItem::Fill(fl) => {
|
||||
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal;
|
||||
if layered {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
if paint::set_paint(ctx, &fl.paint, tb, f.doc) {
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.fill_path(all);
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => {
|
||||
ctx.set_stroke(kurbo::Stroke::new(st.width));
|
||||
ctx.stroke_path(all);
|
||||
AppearanceItem::Stroke(st) if st.width > 0.0 => {
|
||||
if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) {
|
||||
self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, tb);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
AppearanceItem::Stroke(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1155,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_charstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_container;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Strokes on type: character strokes take their join, cap and dashes, and object strokes on type
|
||||
//! take every stroke option, as path strokes do.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::text::{CharStyle, TextObject};
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SIZE: u32 = 200;
|
||||
|
||||
/// 120 pt `text` with its baseline at (40, 150), on a white 200×200 page.
|
||||
fn render_text(text: &str, style: CharStyle, items: Vec<AppearanceItem>) -> Rendered {
|
||||
let mut d = Document::new(SIZE as f64, SIZE as f64);
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 150.0), text, CharStyle { size: 120.0, ..style }))));
|
||||
n.appearance.items = items;
|
||||
n.id = d.alloc_id();
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
fn dark(img: &Rendered, x: u32, y: u32) -> bool {
|
||||
let p = img.pixel(x, y);
|
||||
(p[0] as u32 + p[1] as u32 + p[2] as u32) < 384
|
||||
}
|
||||
|
||||
/// The box of the dark pixels: (x0, y0, x1, y1).
|
||||
fn ink(img: &Rendered) -> (u32, u32, u32, u32) {
|
||||
let mut b = (u32::MAX, u32::MAX, 0, 0);
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
if dark(img, x, y) {
|
||||
b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn stroked(join: LineJoin) -> CharStyle {
|
||||
CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 16.0, stroke_join: join, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_joined_character_strokes_have_no_miter_spikes() {
|
||||
// The glyph alone, then stroked 16 pt: a round join reaches at most half the weight past it,
|
||||
// the default miter join spikes out further at the sharp corners of the A.
|
||||
let glyph = ink(&render_text("A", CharStyle::default(), vec![]));
|
||||
let round = ink(&render_text("A", stroked(LineJoin::Round), vec![]));
|
||||
let miter = ink(&render_text("A", stroked(LineJoin::Miter), vec![]));
|
||||
let reach = |b: (u32, u32, u32, u32)| (glyph.0 - b.0).max(glyph.1 - b.1).max(b.2 - glyph.2).max(b.3 - glyph.3);
|
||||
assert!(reach(round) <= 9, "round joins stay within half the weight: {glyph:?} → {round:?}");
|
||||
assert!(reach(miter) > reach(round) + 2, "miter joins spike: {miter:?} vs {round:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_and_object_strokes_on_type_are_dashed() {
|
||||
// An "I" without a fill: its outline drawn with 8/8 dashes breaks up along the left stem edge.
|
||||
let none = CharStyle { fill: Paint::None, ..Default::default() };
|
||||
let dash = Some(Dash { pattern: vec![8.0, 8.0], ..Default::default() });
|
||||
let transitions = |img: &Rendered| {
|
||||
let (x0, y0, _, y1) = ink(img);
|
||||
let col = x0 + 1;
|
||||
(y0..y1).filter(|y| dark(img, col, *y) != dark(img, col, y + 1)).count()
|
||||
};
|
||||
let solid = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 3.0, ..none.clone() };
|
||||
assert!(transitions(&render_text("I", solid.clone(), vec![])) <= 2, "a solid stroke is continuous");
|
||||
let chars = CharStyle { stroke_dash: dash.clone(), ..solid };
|
||||
assert!(transitions(&render_text("I", chars, vec![])) >= 6, "character dashes");
|
||||
let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
|
||||
st.dash = dash;
|
||||
let object = render_text("I", none, vec![AppearanceItem::Stroke(st.clone())]);
|
||||
assert!(transitions(&object) >= 6, "object dashes");
|
||||
// And its opacity.
|
||||
st.opacity = 0.5;
|
||||
st.dash = None;
|
||||
let half = render_text("I", CharStyle { fill: Paint::None, ..Default::default() }, vec![AppearanceItem::Stroke(st)]);
|
||||
let (x0, y0, _, y1) = ink(&render_text("I", CharStyle::default(), vec![]));
|
||||
let p = half.pixel(x0, (y0 + y1) / 2);
|
||||
assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user