diff --git a/ROADMAP.md b/ROADMAP.md index 9a0f71e70..a11262699 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs index cd3388f76..c49c1d2cb 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 734ec275e..cc46ad729 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);