diff --git a/ROADMAP.md b/ROADMAP.md index 8f5484295e..312078fa50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -445,6 +445,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-19 | A picture, shape or text box dragged near the bottom of a page lands where it is dropped. It used to jump far below the page, cut off at its edge with only its selection box showing over the next page (#3169) | | 2026-10-19 | Insert Caption picks the label used last, as Word does, and keeps it between runs (#3120). | | 2026-10-20 | Pinned layout test fonts also initialize before the new caret-height and ligature fixtures, keeping isolated test runs independent of system fonts (#2729). | +| 2026-10-19 | Dark-theme interface text on Linux and the BSDs no longer looks semi-bold: egui thickened light-on-dark glyph edges by default (about 21 % more ink than Firefox draws the same text); it now adds a light 7 %, chosen side by side on a 145 % desktop (Firefox's plain weight looked soft on the dark tab row). Light-theme interface text was measured against headless Firefox and already matches it (Inter's ttfautohint hints draw as FreeType's light autohinting does). Headless screenshots take a scale (`{"ppp": 1.45}` in `ui_shot`) to see text as a fractional desktop scale draws it. | +| 2026-10-19 | Page text on Linux and the BSDs is no longer soft: it is hinted the way FreeType's `hintslight` draws text in Firefox and other apps there, its autohinter putting baselines, x-heights and cap heights on whole pixels without moving anything sideways, so lines break as before. Measured against headless Firefox on Arial at 21.27 px: the same rows of pixels and the same weight. Re-rendering costs the same; the first render at a new zoom about 1.5 ms more per page. macOS, Windows and exports are unchanged. | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-20 | Russian sorting keeps ё with е and recognises decomposed ё/й: case-insensitive words and index headings share the corrected collation, with regressions and no new dependencies. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | @@ -1078,6 +1080,8 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) | | 2026-10-19 | trivial | Progress log: a picture dragged near the bottom of a page lands there (#3169) | | 2026-10-19 | trivial | Progress log: a Thai run with Latin finds one installed font covering it all on Linux too (#1549) | +| 2026-10-19 | trivial | Progress log: dark-theme interface text on Linux no longer looks semi-bold | +| 2026-10-19 | trivial | Progress log: page text on Linux is hinted like Firefox draws text there | | 2026-10-20 | trivial | Progress log: Times and Courier take their Windows counterparts' line spacing | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 0230895d73..6ae53ac460 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -7,7 +7,9 @@ use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; use skrifa::instance::{Location, LocationRef, Size}; -use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target}; +use skrifa::outline::{ + DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target, +}; use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; @@ -15,8 +17,6 @@ use skrifa::{GlyphId, MetadataProvider}; pub(crate) mod user; pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff}; -static GLOBAL: std::sync::OnceLock = std::sync::OnceLock::new(); - mod catalog; mod face; mod fallback; @@ -41,10 +41,16 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name}; #[cfg(test)] pub(crate) use names::{last_resort_face, legacy_family}; +static GLOBAL: std::sync::OnceLock = std::sync::OnceLock::new(); + /// Process-wide font database. pub struct FontDb { faces: RwLock>>, outlines: Mutex>>, + /// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted. + hinted: Mutex>>>, + /// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted. + hinters: Mutex>>>, /// Fallback faces found per (character, excluded face), valid while the face count is the /// one stored (faces are only ever added). Layout asks once per uncovered character, and the /// search sorts every loaded face, so without this text in a script the requested font lacks @@ -110,6 +116,8 @@ impl FontDb { Self { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), + hinted: Mutex::new(HashMap::new()), + hinters: Mutex::new(HashMap::new()), fallbacks: RwLock::new(FallbackCache::default()), run_fallbacks: RwLock::new(RunFallbackCache::default()), #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/fonts/src/fontdb/face.rs b/crates/fonts/src/fontdb/face.rs index f28507e07f..d3e9e01add 100644 --- a/crates/fonts/src/fontdb/face.rs +++ b/crates/fonts/src/fontdb/face.rs @@ -1,7 +1,7 @@ use super::names::{LocalName, east_asian_font}; use super::{ - Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider, - Ordering, OutlinePen, RwLock, Size, Target, + Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef, + MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target, }; /// The family used when a requested family is unknown (the UI sans). @@ -77,6 +77,8 @@ pub struct FontFace { pub(super) bmp: std::sync::OnceLock>, // Some legacy CJK fonts assemble their contours with TrueType instructions. pub(super) outline_hinter: std::sync::OnceLock>, + /// The autohinter's analysis of the font for screen hinting, made once for every size. + pub(super) glyph_styles: std::sync::OnceLock, /// Hinted draws so far, those that ran too long, and whether that turned hinting off /// ([`FontFace::count_hinted`]). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -288,6 +290,42 @@ impl FontFace { }) .as_ref() } + /// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device + /// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light, + /// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto + /// whole pixels, nothing moves sideways, so advances and layout stay as they are. + pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option { + let f = self.skrifa()?; + let g = f.outline_glyphs().get(GlyphId::new(gid))?; + let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true }; + g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?; + Some(pen.path) + } + + /// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light + /// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored; + /// measured against Firefox on Linux, the autohinter matches its rows of pixels and the + /// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for + /// fonts whose glyphs need their instructions to be assembled at all their instructions with + /// the monochrome target, as skrifa requires for them. + pub(super) fn screen_hinter(&self, ppem: f32) -> Option { + let f = self.skrifa()?; + let outlines = f.outline_glyphs(); + let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true }; + let (engine, target) = if outlines.require_interpreter() { + // FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their + // instructions): skrifa asks for the interpreter with the monochrome target, under + // which the instructions may move points sideways, or glyphs come apart. + (Engine::Interpreter, Target::Mono) + } else if outlines.format() == Some(OutlineGlyphFormat::Glyf) { + (Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light) + } else { + (Engine::Interpreter, light) + }; + HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target }) + .map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family)) + .ok() + } /// Is `family` this face's typographic, legacy, full or PostScript name? pub fn named(&self, family: &str) -> bool { self.family.eq_ignore_ascii_case(family) @@ -358,13 +396,18 @@ impl FontFace { pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1); pub(super) const OUTLINE_CACHE_MAX: usize = 50_000; +/// Hinting instances kept (one per face and text size on screen). +pub(super) const HINTER_CACHE_MAX: usize = 256; +/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the +/// half-pixel hinting moves is invisible (and each size would cost a cache entry). +pub(super) const HINT_PPEM: std::ops::RangeInclusive = 4.0..=96.0; /// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it /// had to drop one). pub(super) struct FlipPen { pub(super) path: BezPath, - room: usize, - over: bool, + pub(super) room: usize, + pub(super) over: bool, /// Drawn with the face's TrueType interpreter. pub(super) hinted: bool, } diff --git a/crates/fonts/src/fontdb/lookup.rs b/crates/fonts/src/fontdb/lookup.rs index dc181ca43a..ba5f37f18a 100644 --- a/crates/fonts/src/fontdb/lookup.rs +++ b/crates/fonts/src/fontdb/lookup.rs @@ -1,4 +1,4 @@ -use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX}; +use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX}; #[cfg(not(target_arch = "wasm32"))] use super::fallback_data::read_fallback_file; #[cfg(not(target_arch = "wasm32"))] @@ -227,4 +227,41 @@ impl FontDb { cache.insert(key, p.clone()); p } + + /// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in + /// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph, + /// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`]. + pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option> { + if !HINT_PPEM.contains(&ppem) { + return None; + } + let key = (face.id, gid, ppem.to_bits()); + if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { + return p.clone(); + } + let hinter = { + let hkey = (face.id, ppem.to_bits()); + let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned(); + match known { + Some(h) => h, + None => { + // Built outside the lock: running a font's hinting programs takes a while. + let h = face.screen_hinter(ppem).map(Arc::new); + let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if cache.len() >= HINTER_CACHE_MAX { + cache.clear(); + } + cache.insert(hkey, h.clone()); + h + } + } + }; + let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new); + let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if cache.len() >= OUTLINE_CACHE_MAX { + cache.clear(); + } + cache.insert(key, p.clone()); + p + } } diff --git a/crates/fonts/src/fontdb/names.rs b/crates/fonts/src/fontdb/names.rs index 0b01041d66..4d9458594a 100644 --- a/crates/fonts/src/fontdb/names.rs +++ b/crates/fonts/src/fontdb/names.rs @@ -394,6 +394,7 @@ pub(super) fn make_face( index, bmp: std::sync::OnceLock::new(), outline_hinter: std::sync::OnceLock::new(), + glyph_styles: std::sync::OnceLock::new(), hint_draws: Default::default(), hint_slow: Default::default(), hinting_off: Default::default(), diff --git a/crates/fonts/src/fontdb/tests.rs b/crates/fonts/src/fontdb/tests.rs index 4734f67bf3..d3f2fd34ab 100644 --- a/crates/fonts/src/fontdb/tests.rs +++ b/crates/fonts/src/fontdb/tests.rs @@ -38,6 +38,7 @@ fn kai_contours_include_complete_character_body() { } } +use super::face::FlipPen; use super::*; #[test] @@ -92,6 +93,67 @@ fn run_fallback_is_cached_per_char_set() { assert!(db.run_fallbacks.read().unwrap().map.is_empty()); } +#[test] +fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() { + let db = FontDb::global(); + let face = db.face(FALLBACK_FAMILY, "Regular"); + let x = face.glyph_for('x'); + // 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens. + let ppem = 11.0 * 96.0 / 72.0 * 1.45; + let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted"); + let plain = db.outline(&face, x); + let k = f64::from(ppem) / face.upem; + let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain)); + // The x-height (the top, y-down) and the baseline sit on the pixel grid… + assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0); + assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1); + assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k); + // …and the sides stay where the font puts them, so advances and line breaks don't change. + assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k)); + // Cached per size. + assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached"))); +} + +#[test] +fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() { + // FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on + // TrueType outlines and ignores their instructions: measured against Firefox, the + // instructions drew Arial's rows elsewhere and heavier. + let db = FontDb::global(); + let face = db.face(FALLBACK_FAMILY, "Regular"); + let font = face.skrifa().unwrap(); + let outlines = font.outline_glyphs(); + assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)"); + let ppem = 11.0 * 96.0 / 72.0 * 1.45; + let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true }; + let drawn = |engine: Engine, gid: u32| { + let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap(); + let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true }; + outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap(); + pen.path + }; + let mut differs = false; + for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) { + let gid = face.glyph_for(c); + let ours = db.hinted_outline(&face, gid, ppem).unwrap(); + assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline"); + differs |= *ours != drawn(Engine::Interpreter, gid); + } + assert!(differs, "the font's own instructions would draw it differently"); +} + +#[test] +fn screen_hinting_refuses_hostile_sizes() { + let db = FontDb::global(); + let face = db.face(FALLBACK_FAMILY, "Regular"); + let x = face.glyph_for('x'); + for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] { + assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}"); + } + // A glyph id past the font's glyphs has no outline to hint. + assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty())); +} + /// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign. const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50"; diff --git a/crates/fonts/src/fontdb/tests/sysfonts.rs b/crates/fonts/src/fontdb/tests/sysfonts.rs index c95adb6b3b..4507c19de1 100644 --- a/crates/fonts/src/fontdb/tests/sysfonts.rs +++ b/crates/fonts/src/fontdb/tests/sysfonts.rs @@ -2,6 +2,7 @@ #[allow(unused_imports)] use std::path::{Path, PathBuf}; +use crate::fontdb::face::FlipPen; use crate::fontdb::*; const FAMILY: &str = "Sysfont Sans3"; @@ -420,6 +421,39 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() { assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3); } +#[test] +fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() { + // FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble + // their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono` + // for them. A bundled font renamed into the list stands in for one. + let dir = font_dir("tricky"); + std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + let face = db.face("Sysfont cpop1", "Regular"); + assert_eq!(face.family, "Sysfont cpop1"); + let font = face.skrifa().unwrap(); + let outlines = font.outline_glyphs(); + assert!(outlines.require_interpreter(), "the name makes it tricky"); + let ppem = 11.0 * 96.0 / 72.0 * 1.45; + let drawn = |target: Target, gid: u32| { + let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap(); + let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true }; + outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap(); + pen.path + }; + // The target decides whether the instructions may move points sideways (the light target's + // backward compatibility forbids it): this font's instructions only move points vertically, + // so the target itself is checked, and the outlines drawn with it. + assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono)); + for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) { + let gid = face.glyph_for(c); + assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target"); + } + // Any other font keeps the light target. + let plain = db.face(FALLBACK_FAMILY, "Regular"); + assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. })); +} + #[test] fn coverage_ranges_hold_exactly_the_mapped_characters() { let data = renamed("SourceSans3-Regular.ttf"); diff --git a/crates/layout/src/math/tests.rs b/crates/layout/src/math/tests.rs index cf5a2f2ea9..4900af15e4 100644 --- a/crates/layout/src/math/tests.rs +++ b/crates/layout/src/math/tests.rs @@ -35,6 +35,7 @@ fn lay_in(math: &Math, lin: &str, display: bool, avail: f32, mp: &MathProps) -> #[test] fn eq_array_rows_without_ampersand_are_centred() { + crate::test_fonts(); // Word centres a row with no `&` in the array (#2617: a case row "a, n odd" above // "|a|, &n even"), whether or not the other rows have one. use wordcraft_doc::math::*; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4015098591..8ca2fc0eac 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7845,6 +7845,7 @@ fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() { #[test] fn chart_text_the_font_lacks_is_drawn_in_a_fallback_face() { + crate::test_fonts(); // #4720: a Chinese legend in a chart drawn in the body font (which has no Chinese) showed boxes. use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign}; use wordcraft_doc::props::Rgb; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 02010b168d..3bfb27f8ce 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -163,6 +163,11 @@ pub struct RenderOptions { /// Thicken glyphs slightly the way macOS draws text on screen ("font smoothing"), so pages /// look like they do in other Mac apps. For on-screen rasters only: exports stay exact. pub text_darkening: bool, + /// Hint text for the screen the way FreeType's `hintslight` does on Linux desktops: glyph + /// baselines sit on whole pixels and their horizontal edges (x-height, cap height) are moved + /// onto the pixel grid, so text looks as crisp as in other apps there. Nothing moves + /// sideways, so lines break as without it. For on-screen rasters only: exports stay exact. + pub hinting: bool, /// Dark page (View › Switch Modes): every colour but pictures has its lightness inverted, so /// white paper turns black and black text white while hues stay the same. Screen only. pub dark: bool, @@ -265,6 +270,7 @@ impl Default for RenderOptions { paper: Rgb::WHITE, mark_color: Rgb(0x2B, 0x57, 0x9A), text_darkening: false, + hinting: false, dark: false, dark_paper: DARK_PAPER, read_paper: None, @@ -491,6 +497,13 @@ fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordc } } +/// Does `view` map page points to device pixels without rotation, skew or mirroring, scaling both +/// axes alike? Only then do hinted outlines (made on the pixel grid) line up with the screen. +fn is_upright(view: Affine) -> bool { + let [a, b, c, d, e, f] = view.as_coeffs(); + [a, b, c, d, e, f].iter().all(|v| v.is_finite()) && b == 0.0 && c == 0.0 && a > 0.0 && (a - d).abs() <= a * 1e-6 +} + /// How far, in device pixels, macOS-style font smoothing pushes each side of a glyph outline out /// at `ppem` device pixels per em. Values from Pathfinder (MIT/Apache-2.0), which measured them /// against macOS: 1.21% of the pixel size horizontally and 1.25 times that vertically, each @@ -729,13 +742,19 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) { let darken_px = if opts.text_darkening && *alpha >= 1.0 { stem_darkening(*size as f64 * px_per_pt) } else { 0.0 }; let darken = if darken_px > 0.0 && k * px_per_pt > 0.0 { 2.0 * darken_px / (k * px_per_pt) } else { 0.0 }; let outline_stroke = if *synth_bold { face.upem * 0.03 } else { 0.0 } + darken; + let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter }; if outline_stroke > 0.0 { - let join = if darken > 0.0 { kurbo::Join::Round } else { kurbo::Join::Miter }; ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join)); } // Emoji and other colour glyphs (#1269) are drawn from the font's colour data. let colour = face.has_color_glyphs(); let ppem = *size as f64 * px_per_pt; + // Hinted outlines are in device pixels: only for an upright, unmirrored, evenly scaled + // view (not rotated text boxes), where the pixel grid lines up with the glyphs. + let hint_ppem = (opts.hinting && !colour && is_upright(view)).then_some(ppem as f32); + // The outline strokes in device pixels, for the hinted outlines. + let stroke_px = outline_stroke * k * px_per_pt; + let mut hinted_stroke = false; for (gid, x, y) in glyphs { let (gx, gy) = (*x as f64, *y as f64); // Written so a NaN position is skipped too. @@ -749,6 +768,23 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) { ctx.set_paint(color(opts.ink(*c), *alpha)); continue; } + if let Some(o) = hint_ppem.and_then(|p| db.hinted_outline(face, *gid, p)) { + if o.elements().is_empty() { + continue; + } + // The baseline on a whole pixel, as the hinting assumes. + let at = view * kurbo::Point::new(gx, gy); + ctx.set_transform(Affine::translate((at.x, at.y.round())) * skew); + ctx.fill_path(&o); + if outline_stroke > 0.0 { + if !hinted_stroke { + ctx.set_stroke(kurbo::Stroke::new(stroke_px).with_join(join)); + hinted_stroke = true; + } + ctx.stroke_path(&o); + } + continue; + } let o = db.outline(face, *gid); if o.elements().is_empty() { continue; @@ -756,6 +792,10 @@ fn draw_glyphs(ctx: &mut RenderContext, fr: &Frame, it: &Draw) { ctx.set_transform(view * Affine::translate((gx, gy)) * skew * Affine::scale(k)); ctx.fill_path(&o); if outline_stroke > 0.0 { + if hinted_stroke { + ctx.set_stroke(kurbo::Stroke::new(outline_stroke).with_join(join)); + hinted_stroke = false; + } ctx.stroke_path(&o); } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 3efb5fafa5..315a5228a8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -290,6 +290,61 @@ fn text_darkening_adds_ink_on_screen_only() { assert!(!RenderOptions::default().text_darkening); } +/// Inked pixels that are only partly covered (grey): an edge between two pixel rows. +fn grey(img: &Rendered) -> usize { + img.pixels.as_chunks::<4>().0.iter().filter(|p| (40..215).contains(&p[0])).count() +} + +/// The first and last pixel columns with any ink. +fn ink_columns(img: &Rendered) -> (usize, usize) { + let w = img.width as usize; + let inked: Vec = img.pixels.as_chunks::<4>().0.iter().enumerate().filter(|(_, p)| p[0] < 250).map(|(i, _)| i % w.max(1)).collect(); + (inked.iter().copied().min().unwrap_or(0), inked.iter().copied().max().unwrap_or(0)) +} + +#[test] +fn hinting_sharpens_screen_text_without_making_it_bolder_or_moving_it() { + let d = Document::from_text("Every Thursday evening the old print shop fills with people who make things. Painters set up by the north windows."); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + // 100 % zoom on a 145 % desktop: glyph edges land between pixels. + let scale = 96.0 / 72.0 * 1.45; + let plain = render_page(&d, &l.pages[0], scale, &RenderOptions::default()); + let hinted = render_page(&d, &l.pages[0], scale, &RenderOptions { hinting: true, ..Default::default() }); + // Fewer grey pixels: the baseline, x-height and cap height fill whole pixel rows. Only + // horizontal edges snap (the vertical stems stay put), so on the fonts the tests resolve + // to (system or bundled) the drop is 4 to 5 %: asked for at least 3 %. + let (g0, g1) = (grey(&plain), grey(&hinted)); + assert!((g1 as f64) < g0 as f64 * 0.97, "grey pixels: plain {g0}, hinted {g1}"); + // The same weight, as FreeType's light hinting draws it: no darker than a few percent. + let (a, b) = (ink(&plain), ink(&hinted)); + assert!(b as f64 > a as f64 * 0.95 && (b as f64) < a as f64 * 1.12, "ink: plain {a}, hinted {b}"); + // Nothing moves sideways: the text starts and ends in the same pixel columns. + let ((l0, r0), (l1, r1)) = (ink_columns(&plain), ink_columns(&hinted)); + assert!(l0.abs_diff(l1) <= 1 && r0.abs_diff(r1) <= 1, "plain {l0}..{r0}, hinted {l1}..{r1}"); + // Off by default: exports and thumbnails are untouched. + assert!(!RenderOptions::default().hinting); +} + +#[test] +fn rotated_or_mirrored_views_are_not_hinted() { + let d = Document::from_text("Rotated text stays as the font draws it."); + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let hint = RenderOptions { hinting: true, ..Default::default() }; + for view in [ + Affine::translate((300.0, -50.0)) * Affine::rotate(0.3) * Affine::scale(1.9), + Affine::translate((600.0, 0.0)) * Affine::scale_non_uniform(-1.9, 1.9), + Affine::scale_non_uniform(1.9, 2.4), + ] { + let plain = render_region(&d, &l.pages[0], 600, 300, view, &RenderOptions::default()); + let hinted = render_region(&d, &l.pages[0], 600, 300, view, &hint); + assert!(ink(&plain) > 0, "{view:?}"); + assert_eq!(plain.pixels, hinted.pixels, "{view:?}"); + } + assert!(is_upright(Affine::translate((3.5, 7.25)) * Affine::scale(1.93))); + assert!(!is_upright(Affine::scale(f64::NAN))); + assert!(!is_upright(Affine::scale(0.0))); +} + #[test] fn page_picture_is_painted_behind_the_text() { let mut png = Vec::new(); diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 906b41f4c9..9142dc1b3c 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -9,6 +9,8 @@ //! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample //! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}` //! starts the way the desktop app does with nothing to open (File › Home, #766). A line +//! `{"width": w}`, `{"height": h}` or `{"ppp": s}` sets the window size in points or its pixels +//! per point (e.g. 1.45 for a 145 % desktop, to see text as it is drawn there). //! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and //! (with `{"network": true}` on a line, downloading is already on) `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary //! folder rather than the person's, or in `"storeDir"` given on the same line (with @@ -57,8 +59,9 @@ fn main() { } let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32; let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32; + let ppp = lines.iter().find_map(|l| l.get("ppp").and_then(|v| v.as_f64())).map(|p| p.clamp(0.5, 4.0)).unwrap_or(2.0) as f32; let mut harness = - egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(2.0).with_max_steps(1_000_000).wgpu().build_ui_state( + egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(ppp).with_max_steps(1_000_000).wgpu().build_ui_state( |ui, app: &mut WordApp| { if !READY.load(std::sync::atomic::Ordering::Relaxed) { return; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 647941308a..36b1dfe8ea 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -168,6 +168,8 @@ pub struct CanvasState { pub viewed_page: Option, /// Effective scale (screen points per document point). pub scale: f32, + /// Whether this frame's pages were drawn hinted ([`hint_pages`]). + pub(crate) pages_hinted: bool, pub canvas_rect: Option, pub focused: bool, /// The page's widget id and the pass that last drew it, so a click elsewhere can hand the @@ -402,6 +404,7 @@ impl Default for CanvasState { scrollbar_page: None, viewed_page: None, scale: PX_PER_PT, + pages_hinted: screen_render_options().hinting, canvas_rect: None, focused: false, widget_id: None, @@ -526,6 +529,8 @@ struct CanvasView { vruler: Option, paper: Color32, animate: bool, + /// Whether this frame draws the pages hinted ([`hint_pages`]). + hinted: bool, opts: egui::InputOptions, geo: Geometry, dim_body: bool, @@ -580,9 +585,12 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C if read_mode { app.canvas.scroll_max = (geo.size - area.size()).max(egui::Vec2::ZERO); } - if (geo.scale - app.canvas.scale).abs() > 1e-6 { + let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-6; + if rescaled { ui.ctx().request_repaint(); } + let hinted = hint_pages(zoom_delta, rescaled); + app.canvas.pages_hinted = hinted; // A zoom moves the scroll offset without anyone scrolling: not passed on to a partner window. let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-4; app.canvas.scale = geo.scale; @@ -612,7 +620,7 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C { app.session.page_hint = c.page; } - CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret, rescaled } + CanvasView { area, hruler, vruler, paper, animate, hinted, opts, geo, dim_body, grid_story, caret, rescaled } } fn update_caret_scroll(app: &mut WordApp, view: &CanvasView) -> Option { @@ -912,6 +920,7 @@ struct PagePaint<'a> { paper: Color32, hidden: Option<(usize, Pos)>, view: u64, + hinted: bool, content_origin: Pos2, ppp: f32, max_tex: f32, @@ -924,6 +933,20 @@ struct PagePaintOutput { rendered: usize, } +/// Are pages drawn hinted this frame (as [`screen_render_options`] says)? Not while a pointer +/// zoom (Ctrl/⌘+wheel easing in, a pinch: `zoom_delta` other than 1) changes the scale +/// (`rescaled`): each such frame is a new size shown for a moment, and hinting every one would +/// cost each page about 1.5 ms more and fill the hint caches with sizes nobody rests on, pushing +/// out the one the zoom ends at. The first frame at rest draws the page hinted (the hinting is in +/// the page's key). A zoom that leaves the scale as it is (at 10 % or 500 %, or over the ribbon) +/// keeps the pages hinted, and other scale changes (the Zoom dialog or slider, a fit zoom +/// following the window) are drawn hinted at once. +fn hint_pages(zoom_delta: f32, rescaled: bool) -> bool { + let gesture = zoom_delta.is_nan() || (zoom_delta - 1.0).abs() > 1e-3; + let zooming = gesture && rescaled; + screen_render_options().hinting && !zooming +} + fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page: &Page, visual_sr: Rect, page_scale: f32) { // View › Gridlines: the drawing grid over the text area (Print Layout, on screen only). if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { @@ -956,7 +979,7 @@ fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page: } fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput { - let PagePaint { app, ui, layout, geo, t, painter, dim_body, paper, hidden, view, content_origin, ppp, max_tex, grid_story } = pass; + let PagePaint { app, ui, layout, geo, t, painter, dim_body, paper, hidden, view, hinted, content_origin, ppp, max_tex, grid_story } = pass; let mut visible_tile_keys = HashSet::new(); let mut rendered = 0; let mut rects = Vec::with_capacity(geo.rects.len()); @@ -985,6 +1008,7 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput { // edit) then sits on the pixel grid in this frame too, rather than a frame later. if visible && !tiled && !fresh && (forced || rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { let mut opts = page_render_options(app, dim_body); + opts.hinting &= hinted; // Only on the page it is on: the `Pos` can't match an object of another page. opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); let img = wordcraft_render::render_page(page_doc(app), page, scale_px, &opts); @@ -1038,6 +1062,7 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput { let fresh = app.canvas.tiles.get(&id).is_some_and(|(k, _)| *k == key); if !fresh && rendered < 4 { let mut opts = page_render_options(app, dim_body); + opts.hinting &= hinted; // Only on the page it is on: the `Pos` can't match an object of another page. opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); // The tiny subtraction avoids a 1px overshoot from round-tripping f32 @@ -1446,7 +1471,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) }; let motion = scroll_motion(app, ui, full, &view); let (scroll_area, scroll_base) = configure_scroll_area(app, ui, &view, motion); - let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, rescaled, .. } = view; + let CanvasView { area, hruler, vruler, paper, hinted, geo, dim_body, grid_story, caret, rescaled, .. } = view; let mut origin = area.min; let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); // The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back. @@ -1468,7 +1493,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let max_tex = ui.ctx().input(|i| i.max_texture_side).min(wordcraft_render::MAX_SIDE as usize).max(1) as f32; let content_origin = ui.min_rect().min; let hidden = app.canvas.dragged_off_page(); - let page_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p)); + let page_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p), hinted); let t0 = crate::now_ms(); let PagePaintOutput { rects, visible_tile_keys, rendered } = paint_pages(PagePaint { app, @@ -1481,6 +1506,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { paper, hidden, view: page_view, + hinted, content_origin, ppp, max_tex, diff --git a/crates/ui-egui/src/canvas/pages.rs b/crates/ui-egui/src/canvas/pages.rs index abb90af8d8..dad5567e15 100644 --- a/crates/ui-egui/src/canvas/pages.rs +++ b/crates/ui-egui/src/canvas/pages.rs @@ -4,8 +4,10 @@ use super::{DocLayout, Hash, Hasher, Page, Placed, Pos, Pos2, Rect, StoryRef, Wo /// marks and markup, dark mode, the page colour and watermark, header/body dimming, the media, /// and (while a drag lasts) the object left out of the page. /// Taken once per frame. -pub(super) fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>) -> u64 { +pub(super) fn view_key(app: &WordApp, dim_body: bool, hidden: Option<&Pos>, hinted: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); + // A page drawn unhinted mid-zoom must not pass for the hinted page (see [`hint_pages`]). + hinted.hash(&mut h); let v = &app.session.view; (v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h); (v.read_mode, v.read_page_color, app.session.prefs.paper_color).hash(&mut h); @@ -263,8 +265,15 @@ pub(super) fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_re opts } -/// Render options for page rasters shown on screen: on macOS, text is darkened the way the system -/// draws it, so a page looks as it does in other Mac apps (exports never are). +/// Render options for page rasters shown on screen, so a page looks as text does in the +/// platform's other apps (exports never change): on macOS, text is darkened the way the system +/// draws it; on Linux and the BSDs it is hinted lightly, as FreeType's `hintslight` draws it +/// there (not darkened: hinted text already has FreeType's weight, and darkening made it +/// look semi-bold). pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions { - wordcraft_render::RenderOptions { text_darkening: cfg!(target_os = "macos"), ..Default::default() } + wordcraft_render::RenderOptions { + text_darkening: cfg!(target_os = "macos"), + hinting: cfg!(all(unix, not(target_os = "macos"))), + ..Default::default() + } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 376964737f..5b817af619 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -345,7 +345,7 @@ fn caret_contrasts_with_what_lies_under_it() { let at = |a: &mut WordApp, off: usize| { let layout = a.session.layout(); let c = layout.caret_on(&Pos::body(0, off), 0).unwrap(); - let view = view_key(a, false, None); + let view = view_key(a, false, None, true); caret_color_at(a, &layout, c.page, c.x, c.top + c.height / 2.0, view, false) }; assert_eq!(at(&mut a, 2), Color32::BLACK, "white paper"); @@ -372,19 +372,19 @@ fn read_mode_page_color_sets_the_reading_paper() { a.run("design.pageColor", json!({"color": "#102040"})).unwrap(); let paper = |a: &WordApp| page_render_options(a, false).paper_of(&a.session.doc); assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "Print Layout shows the page color"); - let print_key = view_key(&a, false, None); + let print_key = view_key(&a, false, None, true); a.run("view.readMode", json!({})).unwrap(); assert_eq!(paper(&a), Rgb::WHITE, "None: white, the page color ignored"); - let none_key = view_key(&a, false, None); + let none_key = view_key(&a, false, None, true); a.run("view.readPageColor", json!({"value": "sepia"})).unwrap(); assert_eq!(paper(&a), wordcraft_render::SEPIA_PAPER); assert!(!page_render_options(&a, false).dark, "Sepia keeps the text's colors"); - let sepia_key = view_key(&a, false, None); + let sepia_key = view_key(&a, false, None, true); a.run("view.readPageColor", json!({"value": "inverse"})).unwrap(); assert!(page_render_options(&a, false).dark); let d = wordcraft_render::DARK_PAPER; assert_eq!(paper(&a), Rgb(d, d, d)); - let inverse_key = view_key(&a, false, None); + let inverse_key = view_key(&a, false, None, true); let keys = [print_key, none_key, sepia_key, inverse_key]; for (i, k) in keys.iter().enumerate() { assert!(!keys[..i].contains(k), "each paper has its own view key"); @@ -4930,9 +4930,27 @@ fn the_view_key_changes_when_a_drag_takes_an_object_off_the_page() { let doc = wordcraft_doc::Document::from_text("Hello WordCraft"); let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default()); let object = Pos::body(0, 5); - let hidden = view_key(&app, false, Some(&object)); - assert_ne!(hidden, view_key(&app, false, None), "the page without the dragged object is rendered again"); - assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame"); + let hidden = view_key(&app, false, Some(&object), true); + assert_ne!(hidden, view_key(&app, false, None, true), "the page without the dragged object is rendered again"); + assert_eq!(hidden, view_key(&app, false, Some(&object), true), "the same drag keeps the key: no re-render every frame"); +} + +/// A zoom easing in (or a pinch) shows a new size every frame for a moment: those pages are +/// drawn unhinted, so they fill no hint caches with sizes nobody rests on. The first frame +/// without zooming draws the page hinted again, even at the size the last zoom frame drew. +#[test] +fn pages_are_hinted_again_once_a_zoom_stops() { + let platform = screen_render_options().hinting; + assert_eq!(hint_pages(1.0, false), platform, "at rest, as the platform draws text"); + for zoom in [1.1, 0.9, 1.002, 0.998, f32::NAN, f32::INFINITY, 0.0] { + assert!(!hint_pages(zoom, true), "{zoom}: the scale changes under a pointer zoom"); + assert_eq!(hint_pages(zoom, false), platform, "{zoom}: at 10 % or 500 %, or over the ribbon: nothing changes"); + } + assert_eq!(hint_pages(1.0, true), platform, "the Zoom dialog or slider, a fit zoom: hinted at once"); + // The hinting is part of the page's key: the last zoom frame's raster is replaced. + let doc = wordcraft_doc::Document::from_text("Hello WordCraft"); + let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default()); + assert_ne!(view_key(&app, false, None, true), view_key(&app, false, None, false)); } #[test] @@ -4942,10 +4960,10 @@ fn screen_paper_color_changes_raster_and_cache_without_changing_export_paper() { let layout = app.session.layout(); let page = &layout.pages[0]; let print_before = wordcraft_render::render_page(&app.session.doc, page, 0.1, &Default::default()); - let mut keys = vec![view_key(&app, false, None)]; + let mut keys = vec![view_key(&app, false, None, true)]; for (value, expected) in [("green", Rgb(0xCC, 0xE8, 0xCF)), ("cream", Rgb(0xFA, 0xF0, 0xDC)), ("gray", Rgb(0xE8, 0xEA, 0xED))] { app.run("view.paperColor", json!({"value":value})).unwrap(); - let key = view_key(&app, false, None); + let key = view_key(&app, false, None, true); assert!(!keys.contains(&key), "each preset invalidates page and tile caches"); keys.push(key); let opts = page_render_options(&app, false); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 1a0590ae13..d3b8a27fa6 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -1742,6 +1742,66 @@ fn wheel_notches_land_at_once_with_animation_effects_off() { assert!(first > 0.0 && first < notch, "a notch eases in again: {first}"); } +/// A Ctrl+wheel notch eases in over several frames, each a new size: those pages are drawn +/// unhinted (they show for a moment, and hinting each size would fill the hint caches), and +/// the first frame after the zoom stops draws them hinted, as the platform draws text. +#[test] +fn an_easing_zoom_draws_unhinted_pages_until_it_stops() { + let ctx = egui::Context::default(); + let system = std::rc::Rc::new(std::cell::Cell::new(Some(true))); + let mut a = app_with_system_animations(&system); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)); + let mut t = 0.0; + let mut frame = |a: &mut WordApp, events: Vec| { + t += 1.0 / 60.0; + let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }) + .drop_without_applying_deltas(); + }; + for _ in 0..3 { + frame(&mut a, Vec::new()); + } + let platform = crate::canvas::screen_render_options().hinting; + assert_eq!(a.canvas.pages_hinted, platform, "at rest"); + let over_page = a.canvas.canvas_rect.unwrap().center(); + frame(&mut a, vec![egui::Event::PointerMoved(over_page)]); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + }; + let before = a.canvas.scale; + frame(&mut a, vec![wheel]); + // The wheel's own frame already starts the zoom, unhinted. + assert_ne!(a.canvas.scale, before, "the zoom starts in the wheel's frame"); + assert!(!platform || !a.canvas.pages_hinted, "the wheel's frame is unhinted"); + let (mut eased, mut unhinted) = (0, 0); + let mut last = a.canvas.scale; + for _ in 0..60 { + frame(&mut a, Vec::new()); + if a.canvas.scale != last { + eased += 1; + unhinted += usize::from(!a.canvas.pages_hinted); + last = a.canvas.scale; + } else { + break; + } + } + assert!(a.canvas.scale > before && eased >= 2, "the notch eased in over frames: {eased}"); + if platform { + assert_eq!(unhinted, eased, "every in-between size unhinted"); + } + // The frame after the zoom stops: hinted again, at the size it ended on. + frame(&mut a, Vec::new()); + assert_eq!(a.canvas.scale, last); + assert_eq!(a.canvas.pages_hinted, platform, "hinted once the zoom stops"); +} + /// With animation effects off a zoom step is finished in its own frame: Ctrl+wheel zooms the /// notch's whole step at once, the page is already placed and drawn as it will stay, and the /// status bar's zoom readout follows in the very next frame. Turns that arrive in small pieces diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ee9347c790..298c9ce5c6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -850,9 +850,20 @@ pub fn apply_animations(ctx: &egui::Context, on: bool) { }); } +/// Coverage exponent for light-on-dark interface text on Linux and the BSDs (see [`visuals`]). +const DARK_TEXT_GAMMA: f32 = 0.8; + /// egui's visuals for one of our palettes. fn visuals(t: &Tokens) -> Visuals { let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; + // Light text on a dark background, on Linux and the BSDs: egui's dark default thickens every + // glyph edge, about a fifth more ink than FreeType gives it in Firefox and the other apps + // there, so the interface looked semi-bold. Plain coverage (Firefox's weight) left the thin + // light strokes on the darkest strips (the tab row) looking soft; a light boost, about 7 % + // more ink, was chosen side by side on a 145 % desktop. + if t.dark && cfg!(all(unix, not(target_os = "macos"))) { + v.text_options.color_transfer_function = egui::epaint::FontColorTransferFunction::Gamma(DARK_TEXT_GAMMA); + } v.panel_fill = t.ribbon; v.window_fill = t.menu; v.extreme_bg_color = t.input; @@ -894,6 +905,51 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 { mod tests { use std::collections::BTreeSet; + /// Ink of a line of interface text at 1.45x (a 145 % desktop) in `appearance`, drawn in the + /// palette's text colour extremes (black on white, white on black) so the two compare. + #[cfg(all(unix, not(target_os = "macos")))] + fn interface_text_ink(appearance: super::Appearance) -> u64 { + let dark = matches!(appearance, super::Appearance::Dark); + let (fg, bg) = if dark { (egui::Color32::WHITE, egui::Color32::BLACK) } else { (egui::Color32::BLACK, egui::Color32::WHITE) }; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(560.0, 40.0)).with_pixels_per_point(1.45).wgpu().build_ui_state( + move |ui, set: &mut bool| { + if !*set { + super::apply(ui.ctx(), appearance); + ui.ctx().set_fonts(super::font_definitions(false, false)); + *set = true; + } + egui::Frame::new().fill(bg).inner_margin(8.0).show(ui, |ui| { + ui.label(egui::RichText::new("Every Thursday evening the old print shop").font(egui::FontId::proportional(12.5)).color(fg)); + }); + }, + false, + ); + h.run(); + let img = h.render().unwrap(); + // Ink: how far each pixel, laid over the background (the window outside the frame is + // transparent), is from the background. + let back = u64::from(bg.r()); + img.pixels() + .map(|p| { + let (v, a) = (u64::from(p.0[0]), u64::from(p.0[3])); + let shown = (v * a + back * (255 - a)) / 255; + shown.abs_diff(back) + }) + .sum() + } + + #[test] + #[cfg(all(unix, not(target_os = "macos")))] + fn dark_interface_text_is_only_a_little_heavier_than_light_interface_text() { + // egui's dark default added about a fifth more ink than FreeType gives the same text in + // Firefox on Linux (semi-bold); plain coverage, Firefox's weight, looked soft on the dark + // tab row. The light boost chosen side by side adds about 7 %. + let (light, dark) = (interface_text_ink(super::Appearance::Light), interface_text_ink(super::Appearance::Dark)); + assert!(light > 0); + let ratio = dark as f64 / light as f64; + assert!((1.03..1.12).contains(&ratio), "light {light}, dark {dark}: {ratio:.3}"); + } + /// WCAG contrast ratio between two opaque colours. pub(super) fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { let lum = |c: egui::Color32| { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 663050c6cb..f1df3808f0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field, and a press beside one shakes it, #3859); previously trivial (showing the Format Shape pane leaves a gradient angle above 359.9° as it is, #3955); previously trivial (Return on File › Print prints, as Word's default Print button does); previously trivial (N-up and booklet printing, #641; readiness unchanged); previously trivial (missing fonts flagged in the font box and status bar, read from what layout drew; metric-compatible substitutes and symbol fonts not flagged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (a long comment's card and balloon stop at ten lines, #3382); previously minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged. @@ -59,6 +59,8 @@ Document title opens Rename Document; the account badge opens Options › Genera | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Document scroll bars | ✅ always drawn beside the page in the room left for them, wherever the pointer is, with a grey thumb that stands out from its track in the light and the dark theme, and more so under the pointer and while dragged; they were invisible until the pointer reached the window's right edge (#5746, `canvas::tests::the_document_scroll_bars_show_with_the_pointer_away`); hidden in Read Mode, as in Word | always shown | 90% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | +| Interface text on screen (Linux, BSD) | Drawn by egui with Inter's ttfautohint hints, which match FreeType's light autohinting: measured against headless Firefox (Inter, 18.1 and 15.2 px at 1.45x), the same rows of pixels and weight (ink within 1 %) in the light theme. The dark theme no longer looks semi-bold: egui's dark default thickened light-on-dark text (about 21 % more ink than Firefox); it now adds a light 7 % (plain coverage, Firefox's weight, looked soft on the dark tab row; chosen side by side on a 145 % desktop). Not yet: subpixel (LCD) edges | sharp interface text | 90% | — | +| Page text on screen (Linux, BSD) | Lightly hinted as FreeType's `hintslight` draws it (Firefox and other apps there): FreeType's autohinter puts baselines, x-heights and cap heights on whole pixels, nothing moves sideways, so lines break as before. Measured against headless Firefox (Arial, 21.27 px): the same rows of pixels and weight. Not yet: subpixel (LCD) edges; the interface text (egui) is hinted separately. macOS keeps its darkened unhinted text; Windows and exports are unchanged | sharp document text | 90% | 4–8 (subpixel edges) | | Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — | | Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None and kept between Read Mode entries, documents and runs (`view.readPageColor`); ink, object drag previews and table drag arrows drawn over the pages follow it; Read Mode's View menu: Column Width and Page Color work, Layout is missing ([parity-checklist.md](parity-checklist.md), Read Mode 2/3) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the page is the caret's while the caret is on screen, else the page at the top of the window, so scrolling updates it (#3880); the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other). The count at the right is what it shows in Word: words (default), characters (with spaces) or characters (no spaces), chosen from a right-click menu on it or File › Options › Display (Status bar count, `ui.statusCount`), kept between runs; a selection shows "selected of total" as the Word Count dialog counts them (#2797) | ✅ | 90% | — | @@ -198,6 +200,8 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-20 | trivial | The scrollbar page tip stays on one line: it broke into two or three lines ("Page 10 of" / "40") as the page number grew during a drag (#3341) | | 2026-10-19 | trivial | Format Page Numbers (Insert › Header & Footer › Page Number ▸ Format Page Numbers…, #1113): the dialog sets the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and whether the pages number on from the section before or start at a number; the ribbon item did nothing before (a plain command call that also cleared the section's start), and `layout.pageNumberFormat` keeps the start when `start` is left out and takes null to continue | | 2026-10-16 | trivial | KeyTips: AltGr (Right Alt with Level 3 Shift on Linux) is not an Alt tap, held or tapped, so AltGr+letter types instead of reaching the badges (#2388) | +| 2026-10-19 | trivial | Dark-theme interface text on Linux and the BSDs no longer looks semi-bold: about 7 % heavier than light-theme text instead of egui's 21 % (chosen side by side); light-theme interface text measured to match Firefox already | +| 2026-10-19 | trivial | Page text on Linux and the BSDs is hinted like FreeType's `hintslight`: as sharp as in Firefox, same weight, same line breaks | | 2026-10-15 | trivial | Three-column header/footer table preset and automatic screen-only cell guides while editing (#2197) | | 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |