fix(render): One underline bar per stretch of text, no seams

The canvas drew an underline (and a strikethrough) as one rectangle per
glyph; where two anti-aliased edges met at a fractional pixel they left
a lighter seam between the letters. Contiguous bars of the same colour
and position are merged into one, as the PDF export already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
moppeeerandClaude Opus 5.5 authored and paaspaas00 committed 2026-10-12 18:29:09 +02:00
1 parent d020b4e85d
commit 1dc33dd280
3 files changed
+57 -3

No files matched your search

+1
View File
@@ -132,6 +132,7 @@ Newest first.
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
- **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change.
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
+39
View File
@@ -163,3 +163,42 @@ fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() {
}
}
}
/// An underline is one bar per stretch of text: a bar per glyph left lighter seams between the
/// letters where the anti-aliased edges met (at fractional positions).
#[test]
fn an_underline_has_no_seams_between_letters() {
let mut d = Document::new(&NewDocument { width: 400.0, height: 120.0, facing_pages: false, ..Default::default() });
let chars = CharAttrs {
font_family: Some("Source Sans 3".into()),
size: Some(48.0),
fill: Some("[Black]".into()),
underline: Some(true),
underline_weight: Some(Some(6.0)),
..Default::default()
};
d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 390.0, 110.0), d.default_layer(), "FERRUM", ParaFormat { chars, ..Default::default() })
.unwrap();
let mut renderer = Renderer::new();
renderer.threads = 0;
let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() };
// A fractional scale puts the letters' edges between pixels.
let (w, h) = (543u32, 163u32);
let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options);
let px = |x: u32, y: u32| {
let i = ((y * w + x) * 4) as usize;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]]
};
// The underline's middle row: the row with the longest run of dark pixels ([Black] shows as
// about 52 grey), then nothing lighter than its darkest pixel along it.
let row = (0..h).max_by_key(|&y| (0..w).filter(|&x| px(x, y)[0] < 110).count()).unwrap();
let dark: Vec<u32> = (0..w).filter(|&x| px(x, row)[0] < 110).collect();
let (x0, x1) = (dark[0], *dark.last().unwrap());
assert!(x1 - x0 > 150, "the underline spans the word: {x0}..{x1}");
// The bar's own anti-aliased ends aside.
let inside = x0 + 2..=x1 - 2;
let ink = inside.clone().map(|x| px(x, row)[0]).min().unwrap();
let light: Vec<(u32, u8)> = inside.map(|x| (x, px(x, row)[0])).filter(|&(_, v)| v > ink + 12).collect();
assert!(light.is_empty(), "seams in the underline at {light:?}");
}
+17 -3
View File
@@ -117,8 +117,8 @@ fn union(r: Option<Rect>, b: Rect) -> Option<Rect> {
fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs {
let mut runs: Vec<(u32, BezPath)> = Vec::new();
let mut underlines = Vec::new();
let mut strikes = Vec::new();
let mut underlines: Vec<(String, f32, Rect)> = Vec::new();
let mut strikes: Vec<(String, f32, Rect)> = Vec::new();
let mut glyphs = 0;
for g in &l.glyphs {
if !g.visible {
@@ -127,7 +127,21 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line
let style = &cs.styles[g.style as usize];
for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] {
if on {
bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g))));
let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g));
// One bar per stretch, as the PDF export draws them: a bar per glyph left
// anti-aliased seams between the letters.
match bars.last_mut() {
Some((color, tint, last))
if *color == rule.color
&& *tint == rule.tint
&& (last.y0 - r.y0).abs() < 1e-6
&& (last.y1 - r.y1).abs() < 1e-6
&& (r.x0 - last.x1).abs() < 0.5 =>
{
last.x1 = last.x1.max(r.x1);
}
_ => bars.push((rule.color.clone(), rule.tint, r)),
}
}
}
let outline = db.outline(&g.face, g.gid);