mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ca343e8985
commit
1e34026a79
@@ -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).
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user