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 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-12 00:29:17 +02:00
co-authored by Claude Opus 5.5
parent ca343e8985
commit 1e34026a79
3 changed files with 57 additions and 3 deletions
+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **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-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
smoke-tested under QEMU).
+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);