mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 06:06:49 -06:00
Optical margin alignment: lines with a tab hang their edge glyphs, as both line breakers measure them (#1760)
layout_line set a line with a tab or a right indent tab without the optical margin or CJK edge hangs, while the greedy breaker and the paragraph composer counted them. A line that fit only because its period hangs was then squeezed into the measure or set past it. layout_line now hangs the edge glyphs of every line. Tabs are measured from the hung start, so tab stops stay where they are, and a right indent tab fills to the hung end. A tab at a line edge hangs nothing. Lines without tabs lay out as before.
This commit is contained in:
@@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change.
|
||||
- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change.
|
||||
- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change.
|
||||
- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change.
|
||||
|
||||
+18
-16
@@ -3419,9 +3419,20 @@ fn layout_line(
|
||||
let h = shape::hyphen_after(g);
|
||||
line.push(h);
|
||||
}
|
||||
// Tabs: compute widths left to right.
|
||||
let measure = x1 - x0;
|
||||
let mut x = 0.0;
|
||||
// Optical margin alignment and hanging punctuation: the glyphs at the line's edges hang
|
||||
// outside the measure. A tab at an edge hangs nothing.
|
||||
let is_tab = |g: &Glyph| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB;
|
||||
let at_edge = |g: &&Glyph| is_tab(g) || (g.adv > 0.0 && !g.is_space());
|
||||
let first = line.iter().find(at_edge);
|
||||
let lastg = line.iter().rev().find(at_edge);
|
||||
let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp));
|
||||
let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp));
|
||||
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
|
||||
let regular_hang = pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular;
|
||||
// Tabs: compute widths left to right. Tab stops stay where they are when the line hangs, so
|
||||
// tabs are measured from the hung start, and a right indent tab reaches the hung end.
|
||||
let mut x = -hl;
|
||||
let mut i = 0;
|
||||
let mut leaders: Vec<(usize, String)> = Vec::new();
|
||||
// The line's last tab and whether it is a left tab (only text after a left tab justifies).
|
||||
@@ -3451,26 +3462,17 @@ fn layout_line(
|
||||
leaders.push((i, t.leader.clone()));
|
||||
}
|
||||
let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum();
|
||||
line[i].adv = (measure - x - rest).max(0.0);
|
||||
let hr = if regular_hang { optical_right } else { optical_right.max(cjk_right) };
|
||||
line[i].adv = (measure + hr - x - rest).max(0.0);
|
||||
last_tab = Some((i, false));
|
||||
}
|
||||
x += line[i].adv;
|
||||
i += 1;
|
||||
}
|
||||
let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB);
|
||||
// Optical margin alignment: the measure grows by the hang of the edge glyphs.
|
||||
// The measure grows by the hang of the edge glyphs.
|
||||
let (mut x0, mut measure) = (x0, measure);
|
||||
if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab {
|
||||
let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space());
|
||||
let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space());
|
||||
let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp));
|
||||
let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp));
|
||||
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
|
||||
let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular {
|
||||
cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0))
|
||||
} else {
|
||||
cjk_right
|
||||
};
|
||||
if sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None {
|
||||
let cjk_right = if regular_hang { cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0)) } else { cjk_right };
|
||||
let hr = optical_right.max(cjk_right);
|
||||
x0 -= hl;
|
||||
measure += hl + hr;
|
||||
|
||||
@@ -1358,6 +1358,61 @@ fn optical_margin_hangs_punctuation() {
|
||||
assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_margin_hangs_the_edges_of_lines_with_tabs() {
|
||||
// Both line breakers let the edge glyphs of a line hang, tab or not, so layout hangs them
|
||||
// too: a line that fits only because its period hangs is not squeezed into the measure or
|
||||
// set past it. The left hang leaves tab stops where they are.
|
||||
let stop = designcraft_doc::TabStop { position: 40.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() };
|
||||
let rest = "right here. More words follow it on the next line.";
|
||||
let cases = [
|
||||
("tab", format!("“A”\tsits {rest}"), Align::LeftJustified),
|
||||
("no tab", format!("“A” sits {rest}"), Align::LeftJustified),
|
||||
("right indent tab", format!("“A” sits{}{rest}", story::RIGHT_INDENT_TAB), Align::Left),
|
||||
];
|
||||
for (name, text, align) in &cases {
|
||||
let ch = |g: &PlacedGlyph| text.get(g.byte..).and_then(|s| s.chars().next()).unwrap_or(' ');
|
||||
let edge_glyphs = |l: &Line| -> Vec<PlacedGlyph> { l.glyphs.iter().filter(|g| g.visible && !ch(g).is_whitespace()).cloned().collect() };
|
||||
for composer in [Composer::Paragraph, Composer::SingleLine] {
|
||||
let mut ending = 0;
|
||||
for w in 100..=240 {
|
||||
let para = ParaAttrs {
|
||||
align: Some(*align),
|
||||
optical_margin: Some(true),
|
||||
hyphenate: Some(false),
|
||||
composer: Some(composer),
|
||||
tabs: Some(vec![stop.clone()]),
|
||||
..Default::default()
|
||||
};
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, f64::from(w), 1000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let Some(last) = edge_glyphs(l).last().cloned() else { continue };
|
||||
let hang = breaker::hang(ch(&last)).1 * last.adv;
|
||||
let end = last.x + last.adv;
|
||||
assert!(end <= l.x1 + hang + 0.01, "{name}, {composer:?}, width {w}: line {i} ends at {end}, past {} and its hang {hang}", l.x1);
|
||||
}
|
||||
let glyphs = edge_glyphs(lines[0]);
|
||||
let (x0, x1) = (lines[0].x0, lines[0].x1);
|
||||
let first = &glyphs[0];
|
||||
assert!((first.x + first.adv - x0).abs() < 0.01, "{name}, {composer:?}, width {w}: the opening quote is at {}", first.x);
|
||||
if *name == "tab" {
|
||||
let s = glyphs.iter().find(|g| ch(g) == 's').unwrap();
|
||||
assert!((s.x - (x0 + 40.0)).abs() < 0.01, "{composer:?}, width {w}: the text after the tab starts at {}", s.x);
|
||||
}
|
||||
// Line 0 ends with the period of "here.": the period hangs past the measure.
|
||||
let last = glyphs.last().unwrap();
|
||||
if text.get(last.byte..).is_some_and(|s| s.starts_with(". More")) {
|
||||
ending += 1;
|
||||
assert!((last.x - x1).abs() < 0.01, "{name}, {composer:?}, width {w}: the period starts at {}, not at {x1}", last.x);
|
||||
}
|
||||
}
|
||||
assert!(ending > 3, "{name}, {composer:?}: line 0 ends with the period at {ending} widths");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin,
|
||||
/// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes.
|
||||
fn quote_hang(text_size: f64, story: Option<f64>, para: ParaAttrs) -> f64 {
|
||||
|
||||
Reference in New Issue
Block a user