mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 11:57:36 -06:00
Merge PR #2681: Layout: a Symbol bullet's line keeps Symbol's height without the font
docs/layout-parity.md: one status line naming this change first, then main's; history rows union
This commit is contained in:
@@ -152,6 +152,32 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ascent and descent (em) of Word's symbol fonts, which aren't open fonts. Their characters are
|
||||
/// drawn from other faces (`wordcraft_doc::symbol_fonts`), but Word sizes a line by the font
|
||||
/// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's
|
||||
/// PDFs give the fonts (`/Ascent`, `/Descent`).
|
||||
pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> {
|
||||
match family.trim().to_ascii_lowercase().as_str() {
|
||||
"symbol" | "symbol (pcl6)" => Some((1.005, 0.216)),
|
||||
"wingdings" => Some((0.899, 0.205)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the
|
||||
/// resolved face's own: the font isn't installed (`resolved` is a substitute), or the installed
|
||||
/// face is one em tall, as Apple's Symbol is (hhea 0.701 / -0.299, no gap). Word for Mac spaces
|
||||
/// such lines like Windows' Symbol: 1.222 em at 11 and 20 pt on synthetic documents (#2681).
|
||||
pub fn symbol_line(family: &str, resolved: &Resolved) -> Option<(f64, f64)> {
|
||||
let m = symbol_font_metrics(family)?;
|
||||
(resolved.substituted || one_em(line_metrics(&resolved.face), resolved.face.upem)).then_some(m)
|
||||
}
|
||||
|
||||
/// Is a line's (ascent, descent) one `upem` em tall?
|
||||
fn one_em((ascent, descent): (f64, f64), upem: f64) -> bool {
|
||||
upem > 0.0 && ((ascent + descent) / upem - 1.0).abs() < 0.005
|
||||
}
|
||||
|
||||
/// The font a Windows 3.1-era charset name stands for: `Times New Roman Cyr`, `Arial CE`,
|
||||
/// `Courier New Baltic`, `Arial (Hebrew)`… are aliases Windows keeps (its FontSubstitutes) for
|
||||
/// the font itself, and old Russian and Central European documents name them. Other names are
|
||||
@@ -481,6 +507,27 @@ mod tests {
|
||||
assert_eq!(substitutes(charset_base_family("Times New Roman Cyr")), substitutes("Times New Roman"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcl6_symbol_has_symbols_line_metrics() {
|
||||
assert_eq!(symbol_font_metrics("Symbol (PCL6)"), symbol_font_metrics("Symbol"));
|
||||
assert_eq!(symbol_font_metrics("Arial"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_em_symbol_face_takes_windows_symbols_line() {
|
||||
// Apple's Symbol (2048 units): hhea 1436 / -612, no gap, one em; Windows' Symbol line is 1.221 em.
|
||||
assert!(one_em(hhea_line(1436, -612, 0), 2048.0));
|
||||
assert!(!one_em((1005.0, 216.0), 1000.0));
|
||||
assert!(!one_em((899.0, 211.0), 1000.0));
|
||||
assert!(!one_em((0.0, 0.0), 0.0));
|
||||
let arial = resolve("Arial", false, false);
|
||||
assert_eq!(symbol_line("Arial", &arial), None);
|
||||
let symbol = resolve("Symbol", false, false);
|
||||
if symbol.substituted {
|
||||
assert_eq!(symbol_line("Symbol", &symbol), symbol_font_metrics("Symbol"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcl6_symbol_resolves_as_symbol() {
|
||||
let actual = resolve("Symbol (PCL6)", false, false);
|
||||
|
||||
@@ -858,7 +858,12 @@ impl<'a> Builder<'a> {
|
||||
}
|
||||
}
|
||||
let size = rc.draw_size() * if small { 0.8 } else { 1.0 };
|
||||
let (a, d) = wordcraft_fonts::word::line_metrics(&face);
|
||||
// A symbol font that isn't installed, or is installed one em tall (Apple's Symbol), takes
|
||||
// the height Word gives it.
|
||||
let (a, d) = match wordcraft_fonts::word::symbol_line(&rc.font, &r) {
|
||||
Some((a, d)) => (a * face.upem, d * face.upem),
|
||||
None => wordcraft_fonts::word::line_metrics(&face),
|
||||
};
|
||||
let k = size as f64 / face.upem.max(1.0);
|
||||
// A fallback face (letters the requested font lacks) is emboldened or slanted when it has
|
||||
// no bold or italic of its own.
|
||||
@@ -1829,8 +1834,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
for c in &cl {
|
||||
style = c.style;
|
||||
if let Some(st) = b.styles.get(style as usize) {
|
||||
let a = wordcraft_fonts::word::line_metrics(&st.face).0 * st.size as f64 / st.face.upem.max(1.0);
|
||||
ascent = ascent.max(a as f32 + st.shift.max(0.0));
|
||||
ascent = ascent.max(st.ascent + st.shift.max(0.0));
|
||||
}
|
||||
let g0 = glyphs.len();
|
||||
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
|
||||
@@ -1231,6 +1231,50 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() {
|
||||
assert_eq!(label.glyphs.len(), 1);
|
||||
}
|
||||
|
||||
/// The label's ascent, the text size, and the first line's height and text ascent of a one-line
|
||||
/// bulleted paragraph whose bullet is in `label_font`; no label font = no bullet.
|
||||
fn bullet_first_line(label_font: Option<&str>) -> (f32, f32, f32, f32) {
|
||||
let mut d = Document::from_text("one");
|
||||
if let Some(label_font) = label_font {
|
||||
let id = d.numbering.add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}'));
|
||||
for a in &mut d.numbering.abstracts {
|
||||
if let Some(l) = a.levels.first_mut() {
|
||||
l.chr.font = Some(label_font.into());
|
||||
}
|
||||
}
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!("no text") };
|
||||
let size = para.styles.iter().map(|s| s.size).fold(0.0f32, f32::max);
|
||||
(para.label.as_ref().map_or(0.0, |lab| lab.ascent), size, para.lines[0].height, para.lines[0].text_asc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_bullet_sizes_its_line_by_the_symbol_font() {
|
||||
// Measured in Word: a Symbol bullet makes its line taller than the text alone (Calibri 11 pt
|
||||
// at 1.08 spacing: 15.1 pt, not 14.5 pt), the extra ascent added unscaled. Symbol isn't an open
|
||||
// font; without it the bullet is drawn from another face but its line keeps Symbol's height.
|
||||
if !wordcraft_fonts::word::resolve("Symbol", false, false).substituted {
|
||||
return;
|
||||
}
|
||||
let (ascent, size, height, text_asc) = bullet_first_line(Some("Symbol"));
|
||||
let want = (1.005 * f64::from(size)) as f32;
|
||||
assert!((ascent - want).abs() < 0.01, "{ascent} vs {want}");
|
||||
// The line is the text's own height plus the bullet's extra ascent, unscaled.
|
||||
let (_, _, plain, _) = bullet_first_line(None);
|
||||
let extra = ascent - text_asc;
|
||||
assert!(extra > 0.0, "Symbol's ascent {ascent} is above the text's {text_asc}");
|
||||
assert!((height - (plain + extra)).abs() < 0.01, "{height} vs {plain} + {extra}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pcl6_symbol_bullet_sizes_its_line_as_symbol() {
|
||||
// `Symbol (PCL6)` is drawn with Symbol: an installed Symbol gives both their own face's height,
|
||||
// a missing one gives both Symbol's.
|
||||
assert_eq!(bullet_first_line(Some("Symbol (PCL6)")), bullet_first_line(Some("Symbol")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcl6_symbol_draws_greater_equal() {
|
||||
let mut d = Document::from_text("\u{F0B3}");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (an equation-array row without `&` is centred, #2617; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -37,7 +37,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Missing Arial Unicode MS tries Arial then the Arial substitutes, case-insensitively (`fonts::word::tests::unicode_arial_is_proportional`); glyph coverage and exact Word line height can differ. Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner |
|
||||
| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 |
|
||||
| Full font names in document font requests | ✅ | Exact English (or first available) OpenType name ID4 lookup in loaded faces and the native lazy catalog; family and legacy-family matches keep priority. No style-suffix guessing; named variable-instance aliases remain unsupported | 0 |
|
||||
| Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | 0 |
|
||||
| Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font`. Apple's one-em Symbol takes the same height, as in Word for Mac (`fonts::word::tests::a_one_em_symbol_face_takes_windows_symbols_line`) | 0 |
|
||||
| List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — |
|
||||
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); other line-spacing combinations remain (#108, #135 open) | 3–5 |
|
||||
@@ -259,6 +259,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo
|
||||
| 2026-10-17 | trivial | An equation-array row without `&` is centred among aligned rows (#2617) |
|
||||
| 2026-10-15 | trivial | The caret, clicks and selection edges fall between the letters of a ligature (#1974) |
|
||||
| 2026-10-15 | trivial | Triple borders draw three lines (screen and PDF), not two (#1740) |
|
||||
| 2026-10-17 | trivial | A Symbol or Wingdings run without the font keeps that font's line height: a Symbol bullet's line is 15.1 pt in Calibri 11 pt at 1.08 spacing, as in Word (was 14.5 pt). On a 251-document corpus checked against Word PDFs, page breaks at the same word 45.4% → 50.1% |
|
||||
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
|
||||
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
|
||||
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |
|
||||
|
||||
Reference in New Issue
Block a user