From 1dc33dd280d057f694b1d3661886069ddddead64 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Mon, 12 Oct 2026 00:29:17 +0200 Subject: [PATCH] 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 --- ROADMAP.md | 1 + crates/engine/tests/text_decorations.rs | 39 +++++++++++++++++++++++++ crates/render/src/text.rs | 20 +++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b7163c7..66083a13 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs index cd3388f7..c49c1d2c 100644 --- a/crates/engine/tests/text_decorations.rs +++ b/crates/engine/tests/text_decorations.rs @@ -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 = (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:?}"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275..cc46ad72 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -117,8 +117,8 @@ fn union(r: Option, b: Rect) -> Option { 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);