mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04: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 <[email protected]>
This commit is contained in:
1 parent
d020b4e85d
commit
1dc33dd280
3 files changed
+57
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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