From db40218790bb3a69cf4242bd9c0c3466ae4eddc0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 06:08:13 -0800 Subject: [PATCH] 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. --- crates/render/src/renderer/text.rs | 11 +++++--- crates/render/src/tests/typeedges.rs | 41 ++++++++++++++++++++++++++++ docs/mcp.md | 4 ++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index 95728dbde..c5e2b666c 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -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>> { 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 last.push(p), + Some(last) if joins && solid && !own_fill => last.push(p), _ => groups.push(vec![p]), } joins = solid; diff --git a/crates/render/src/tests/typeedges.rs b/crates/render/src/tests/typeedges.rs index 946dd5d21..f1fb75458 100644 --- a/crates/render/src/tests/typeedges.rs +++ b/crates/render/src/tests/typeedges.rs @@ -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| p.chunks(4).map(|p| p[3]).collect::>(); + 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 = (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] diff --git a/docs/mcp.md b/docs/mcp.md index 0c4e0950e..48d753c3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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