Type: Crisp and Strong reshape the inner edge of a character stroke

A run's solid fill and its stroke no longer share one edge group, so the
stroke's edge inside the characters takes the method. docs/mcp.md says the
native document an SVG, PDF or EPS file carries keeps each frame's method.
This commit is contained in:
dotCooCoo
2026-11-01 06:08:13 -08:00
parent 74b8d31183
commit db40218790
3 changed files with 51 additions and 5 deletions
+7 -4
View File
@@ -553,9 +553,10 @@ const MAX_TYPE_PAINTS: usize = 8;
/// own fills and strokes below the characters, the runs' fills and strokes, then the object's own
/// fills and strokes above the characters. Each of the object's own fills and strokes is a group,
/// and so is each fill and stroke of a run, except that fills and strokes of runs one after
/// another in solid colors are one group when colors are drawn opaque (`opaque`). Paints that
/// draw nothing are left out. `None` for type with more than [`MAX_TYPE_PAINTS`] groups, which is
/// drawn as one.
/// another in solid colors are one group when colors are drawn opaque (`opaque`). A run's stroke
/// starts a new group rather than join its own fill's, so the stroke's edges inside the characters
/// take the method too. Paints that draw nothing are left out. `None` for type with more than
/// [`MAX_TYPE_PAINTS`] groups, which is drawn as one.
fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option<Vec<Vec<TypePaint>>> {
let shown = |i: &AppearanceItem| i.visible() && !i.paint().is_none() && !matches!(i, AppearanceItem::Stroke(st) if st.width <= 0.0);
let items = &n.appearance.items;
@@ -571,8 +572,10 @@ fn type_paints(n: &Node, t: &TextObject, g: &TextGeom, opaque: bool) -> Option<V
let stroke = run.style.has_stroke().then_some((TypePaint::Stroke(i), &run.style.stroke));
for (p, paint) in [fill, stroke].into_iter().flatten() {
let solid = opaque && matches!(paint, vectorcraft_color::Paint::Solid { .. });
// A run's fill and stroke overlap along every outline, so they never share a group.
let own_fill = matches!(p, TypePaint::Stroke(_)) && groups.last().is_some_and(|last| last.contains(&TypePaint::Fill(i)));
match groups.last_mut() {
Some(last) if joins && solid => last.push(p),
Some(last) if joins && solid && !own_fill => last.push(p),
_ => groups.push(vec![p]),
}
joins = solid;
+41
View File
@@ -190,6 +190,47 @@ fn the_methods_reshape_each_paint_on_its_own() {
}
}
/// Crisp and Strong reshape the inner edge of a character stroke, where the stroke lies on the
/// characters' fill in another color: at a pixel the fill covers wholly, the stroke shows the
/// method's share of its own coverage.
#[test]
fn the_methods_reshape_a_character_strokes_inner_edge() {
let opts = RenderOptions::default();
// "Hamburg" with the characters' fill `fill` and a 1.5 pt stroke `stroke`, drawn with `m`.
let draw = |m: TypeAntiAlias, fill: Paint, stroke: Paint| {
let mut d = page(m, None);
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
let s = &mut t.runs[0].style;
(s.fill, s.stroke, s.stroke_width) = (fill, stroke, 1.5);
}
Renderer::new().render(&d, W, H, Affine::IDENTITY, &opts).pixels
};
let black = || Paint::solid(Color::BLACK);
let alpha = |p: Vec<u8>| p.chunks(4).map(|p| p[3]).collect::<Vec<u8>>();
let fill = alpha(draw(TypeAntiAlias::Sharp, black(), Paint::None));
let stroke = alpha(draw(TypeAntiAlias::Sharp, Paint::None, black()));
// Pixels inside the fill that the stroke covers in part: the stroke's inner edge.
let inner: Vec<usize> = (0..fill.len()).filter(|i| fill[*i] == 255 && stroke[*i] > 8 && stroke[*i] < 247).collect();
assert!(inner.len() > 50, "{} pixels on the stroke's inner edge", inner.len());
for m in [TypeAntiAlias::Crisp, TypeAntiAlias::Strong] {
let drawn = draw(m, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let wrong: Vec<_> = inner
.iter()
.filter(|i| {
let c = stroke[**i];
let near = [c.saturating_sub(1), c, c.saturating_add(1)].map(|c| edge_coverage(m, c));
let (lo, hi) = (near.iter().min().copied().unwrap_or(0), near.iter().max().copied().unwrap_or(255));
let red = drawn[**i * 4];
red < lo.saturating_sub(2) || red > hi.saturating_add(2)
})
.take(5)
.map(|i| (*i, stroke[*i], drawn[*i * 4]))
.collect();
assert!(wrong.is_empty(), "{m:?}: (pixel, stroke coverage, red) {wrong:?}");
}
}
/// Crisp and Strong type with several faint fills of the object draws the pixels inside the
/// characters as Sharp draws them: the fills are composited there before their colors are rounded.
#[test]
+3 -1
View File
@@ -5065,7 +5065,9 @@ inline graphics or color glyphs (bitmap or COLR), when its own fills or strokes
stroke or a blend mode other than Normal, when it overprints while Overprint Preview is on, and on the canvas while it
shows a substitution highlight.
With Preferences › General › Anti-aliased Artwork off, the canvas draws all its art with hard edges, type included, and
so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export are unchanged and carry no setting.
so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native
document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's
method, and opening the file restores it.
Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step.
```json