diff --git a/ROADMAP.md b/ROADMAP.md index a0e48bfad8..9fc44b21a7 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 (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 (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. > @@ -444,13 +444,17 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-19 | 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 was a plain command call that did nothing and cleared the section's start | | 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 | 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). | +| 2026-10-17 | Layout unit tests use the pinned bundled fonts without system/user fonts or optional craft-fonts inputs (#2729); every test initializes the fixture before font lookup, and pagination fixtures are measured against that fixed set | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | | 2026-10-17 | Tracked style-property changes (#2697): Modify Style and Update Style to Match Selection record the paragraph/run properties as they were while Track Changes is on (`w:pPrChange` / `w:rPrChange` inside the style), the Reviewing Pane lists them at the start of the document, accepting keeps the new properties and rejecting restores the old ones; the change and revision round-trip through DOCX | +| 2026-10-31 | Control/MCP #2596 review: write/flush failures after sending begins are outcome-unknown; image exports to a path are not replayed; accept-thread startup failures disable the server and remove the key; invalid UTF-8 is rejected per line without ending stdio. Synthetic regressions cover partial JSON delivery at EOF, flush failure, export vs in-memory retry and failed startup. | +| 2026-10-16 | Control/MCP delivery (#1326, #1331): the MCP bridge never re-sends a mutation whose outcome is unknown (explicit "outcome unknown" error; reads and not-sent requests still retry); a control request whose caller timed out while queued never runs, and a timeout before execution is told apart from an unknown outcome; bounded MCP and reply line reads; capped unauthenticated control connections with fallible thread creation | | 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input | | 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows | | 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). | @@ -887,6 +891,7 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged | | 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) | | 2026-11-08 | trivial | Progress log: contextual spacing drops the space after a cell's last Normal paragraph (#5450) | | 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) | diff --git a/apps/wordcraft/src/control_server.rs b/apps/wordcraft/src/control_server.rs index 2b8f4137d7..ba8553ec35 100644 --- a/apps/wordcraft/src/control_server.rs +++ b/apps/wordcraft/src/control_server.rs @@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::{Duration, Instant}; @@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20; /// A connection that sends no authorised request in this time is closed. const FIRST_REQUEST: Duration = Duration::from_secs(30); +/// How long a caller waits for the UI to answer a request. A request still queued after this +/// is cancelled and never runs. +const REPLY_WAIT: Duration = Duration::from_secs(60); + +/// Connections that have not yet sent an authorised request are limited to this many; more are +/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`]. +const MAX_UNAUTHENTICATED: usize = 64; + +/// Counts one unauthenticated connection until it authenticates or ends. +struct Admission(Option>); + +impl Admission { + fn enter(count: &Arc, max: usize) -> Option { + if count.fetch_add(1, Ordering::SeqCst) >= max { + count.fetch_sub(1, Ordering::SeqCst); + return None; + } + Some(Self(Some(count.clone()))) + } + fn release(&mut self) { + if let Some(c) = self.0.take() { + c.fetch_sub(1, Ordering::SeqCst); + } + } +} + +impl Drop for Admission { + fn drop(&mut self) { + self.release(); + } +} + /// The key file of a running control server; [`KeyFile::remove`] it when the window closes. pub struct KeyFile(PathBuf); @@ -37,6 +70,15 @@ impl KeyFile { /// cannot be bound or there is no random key (the control channel is off); the key file is /// `None` when it could not be written (requests are then refused). pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver, Option)> { + start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST)) +} + +fn start_with( + port: u16, + ctx: egui::Context, + settings: Option<&Path>, + serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result>, +) -> Option<(Receiver, Option)> { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { @@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<( None } }; + let rx = match serve(listener, ctx, key) { + Ok(rx) => rx, + Err(e) => { + if let Some(f) = &key_file { + f.remove(); + } + log::error!("control server off: cannot start the accept thread ({e})"); + return None; + } + }; match &key_file { Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()), None => log::info!("control server listening on 127.0.0.1:{port}"), } - Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file)) + Some((rx, key_file)) } /// A new key: 32 random bytes from the operating system, as hex. @@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool { !b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } -fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver { +fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result> { + serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED }) +} + +/// The time and count budgets of a server (small ones in tests). +#[derive(Clone, Copy)] +struct Limits { + first: Duration, + wait: Duration, + max_unauthenticated: usize, +} + +fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result> { + serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ())) +} + +fn serve_with_spawner( + listener: TcpListener, + ctx: egui::Context, + key: String, + limits: Limits, + spawn: impl FnOnce(Box) -> std::io::Result<()>, +) -> std::io::Result> { let (tx, rx) = channel::(); let key: Arc = key.into(); - std::thread::spawn(move || { + let unauthenticated = Arc::new(AtomicUsize::new(0)); + let accept = move || { for stream in listener.incoming().flatten() { + let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else { + log::warn!("control server: too many unauthenticated connections, one closed"); + continue; + }; let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone()); - std::thread::spawn(move || serve(stream, tx, ctx, &key, first)); + // A thread that cannot start closes this connection only (the guard releases its slot). + if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) { + log::warn!("control server: no thread for a connection: {e}"); + } } - }); - rx + }; + spawn(Box::new(accept))?; + Ok(rx) } /// The read side of a connection with an optional overall deadline: each read waits only for @@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader) { } } -fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: &str, first: Duration) { +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; // One overall deadline from accept to the first authorised request; after it, none. - let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) }); + let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) }); let mut authorised = false; // The key an `auth` request gave this connection. let mut conn_key: Option = None; @@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: } if !authorised { authorised = true; + admission.release(); reader.get_mut().set_deadline(None); } if method == "auth" { @@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: } continue; } - let (req, rrx) = ControlRequest::new(method, params); + let (req, rrx, waiter) = ControlRequest::with_waiter(method, params); if tx.send(req).is_err() { break; } ctx.request_repaint(); - let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| { + if waiter.cancel() { + json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"}) + } else { + json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"}) + } + }); if !reply(r) { break; } @@ -260,7 +350,7 @@ mod tests { fn boot_with(first: Duration) -> (u16, Receiver) { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let port = listener.local_addr().unwrap().port(); - (port, serve_on(listener, egui::Context::default(), KEY.to_string(), first)) + (port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap()) } fn boot() -> (u16, Receiver) { @@ -512,6 +602,40 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() { + let dir = temp_dir("spawn-failure"); + let path = wordcraft_control_key::key_path(&dir, 0); + let mut bound = None; + let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| { + bound = Some(listener.local_addr().unwrap()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning"); + serve_with_spawner(listener, ctx, key, small(150, 8), |accept| { + drop(accept); + Err(std::io::Error::other("injected accept thread spawn failure")) + }) + }); + assert!(result.is_none(), "a server without its accept thread must not report success"); + assert!(!path.exists(), "failed startup must remove its published key"); + let _listener = TcpListener::bind(bound.unwrap()).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_complete_json_request_at_eof_can_reach_the_ui() { + // This is why a bridge write failure before the newline is outcome-unknown. + let (port, rx) = boot(); + let mut c = Conn::open(port); + let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}}); + c.1.write_all(body.to_string().as_bytes()).unwrap(); + c.1.shutdown(std::net::Shutdown::Write).unwrap(); + let req = answer(&rx); + assert_eq!(req.params["params"]["text"], "once"); + assert_eq!(c.recv()["ok"], true); + assert!(c.closed()); + assert!(rx.try_recv().is_err()); + } + #[test] fn a_stale_key_file_is_replaced() { let dir = temp_dir("stale"); @@ -532,4 +656,52 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + fn boot_limits(limits: Limits) -> (u16, Receiver) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + (port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap()) + } + + fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits { + Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated } + } + + #[test] + fn a_request_that_timed_out_in_the_queue_never_runs() { + let (port, rx) = boot_limits(small(150, 8)); + let mut c = Conn::open(port); + let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#)); + assert_eq!(r["ok"], false); + assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}"); + // The UI gets to it late: it must not run it. + let req = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(!req.start(), "a cancelled request was started"); + } + + #[test] + fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() { + let (port, rx) = boot_limits(small(150, 8)); + let mut c = Conn::open(port); + c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#)); + let req = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(req.start()); + let r = c.recv(); + assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}"); + } + + #[test] + fn unauthenticated_connections_are_limited() { + let (port, rx) = boot_limits(small(5_000, 2)); + let mut idle = [Conn::open(port), Conn::open(port)]; + // Give the server time to count both. + std::thread::sleep(Duration::from_millis(200)); + assert!(Conn::open(port).closed(), "the third idle connection was admitted"); + // One authenticates: its slot is free again. + assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true); + std::thread::sleep(Duration::from_millis(200)); + let mut again = Conn::open(port); + assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true); + assert!(rx.try_recv().is_err()); + } } diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 8c0322a7d6..eef1b75427 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -8,6 +8,10 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g [lints] workspace = true +[features] +# Opt-in test harness support; enabling this alone never changes the application database. +test-support = [] + [dependencies] kurbo = { workspace = true } skrifa = { workspace = true } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index c8c474500f..0230895d73 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -15,6 +15,8 @@ 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; @@ -90,10 +92,14 @@ impl FontDb { /// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without /// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`. pub fn with_font_dirs(font_dirs: Vec) -> Self { - let mut faces = Vec::new(); let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan")); let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes); - for data in BUNDLED.iter().copied().chain(craft) { + Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft)) + } + + fn with_static_fonts(font_dirs: Vec, fonts: impl Iterator) -> Self { + let mut faces = Vec::new(); + for data in fonts { for f in enumerate_faces(data, MAX_FILE_FACES) { if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) { face.local_names = f.local; @@ -144,8 +150,26 @@ impl FontDb { /// first time a lookup by family name needs them (or ahead of time by /// [`FontDb::scan_in_background`]). pub fn global() -> &'static FontDb { - static DB: std::sync::OnceLock = std::sync::OnceLock::new(); - DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs())) + GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs())) + } + + /// Initialize a test process with only the repository's pinned bundled fonts: no optional + /// craft-fonts, user fonts or system font directories. Call before any global font lookup. + /// Returns false if a normal global database was already initialized; never replaces it. + /// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing. + #[cfg(feature = "test-support")] + pub fn init_test_fonts() -> bool { + static INIT: std::sync::OnceLock = std::sync::OnceLock::new(); + *INIT.get_or_init(|| { + let mut installed = false; + GLOBAL.get_or_init(|| { + installed = true; + let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied()); + db.set_system_fallback(false); + db + }); + installed + }) } fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { diff --git a/crates/fonts/tests/fixed_test_fonts.rs b/crates/fonts/tests/fixed_test_fonts.rs new file mode 100644 index 0000000000..3f644ff624 --- /dev/null +++ b/crates/fonts/tests/fixed_test_fonts.rs @@ -0,0 +1,31 @@ +//! Separate process: verifies the global test fixture before any other lookup initializes it. +#![cfg(feature = "test-support")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use wordcraft_fonts::FontDb; + +#[test] +fn fixed_fonts_exclude_system_and_optional_faces() { + let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect(); + assert!(threads.into_iter().all(|t| t.join().unwrap())); + let db = FontDb::global(); + let families = [ + "Inter", + "Inter Medium", + "Inter SemiBold", + "JetBrains Mono", + "Source Sans 3", + "Source Sans 3 Semibold", + "Source Serif 4", + "Source Serif 4 Semibold", + ]; + assert_eq!(db.families(), families); + assert_eq!(db.load_system_fonts(), 0); + assert_eq!(db.families(), families); + for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] { + assert!(!db.has_family(family), "external family {family} leaked into the test fixture"); + } + assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts"); + assert_eq!(db.loaded_font_bytes(), 0); + assert!(FontDb::init_test_fonts()); +} diff --git a/crates/fonts/tests/system_fonts.rs b/crates/fonts/tests/system_fonts.rs index 7359278dbf..8b79354e69 100644 --- a/crates/fonts/tests/system_fonts.rs +++ b/crates/fonts/tests/system_fonts.rs @@ -19,3 +19,11 @@ fn a_fresh_session_finds_installed_fonts_by_name() { assert_eq!(&FontDb::global().face(family, "Regular").family, family); assert!(FontDb::global().families().contains(family)); } + +#[cfg(feature = "test-support")] +#[test] +fn test_initialization_cannot_replace_an_application_database() { + let db = FontDb::global(); + assert!(!FontDb::init_test_fonts()); + assert!(std::ptr::eq(db, FontDb::global())); +} diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index b307bb758e..21762d5039 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -25,6 +25,7 @@ log = { workspace = true } [dev-dependencies] wordcraft-doc = { workspace = true, features = ["test-support"] } +wordcraft-fonts = { workspace = true, features = ["test-support"] } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] # The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown). diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 90285ecb52..9b92cb6e82 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -349,6 +349,7 @@ mod tests { #[test] fn occupied_follows_the_outline() { + crate::test_fonts(); let c = diamond(false); let s = c.occupied(0.0, 10.0); assert_eq!(s.len(), 1); @@ -363,6 +364,7 @@ mod tests { #[test] fn through_keeps_interior_gaps() { + crate::test_fonts(); // A U: two prongs joined at the bottom. let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap(); @@ -374,6 +376,7 @@ mod tests { #[test] fn pictures_wrap_around_their_opaque_pixels() { + crate::test_fonts(); let png = |img: image::DynamicImage| { let mut buf = Vec::new(); img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap(); @@ -429,6 +432,7 @@ mod tests { #[test] fn hostile_points_are_dropped() { + crate::test_fonts(); assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none()); let c = diamond(true); assert!(c.occupied(f32::NAN, 1.0).is_empty()); diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index 059358ad82..c1a319a757 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -815,6 +815,8 @@ pub fn section_break_label(start: SectionStart) -> &'static str { /// Width of a [`Draw::MarkText`] label. pub fn mark_text_width(text: &str, size: f32) -> f32 { + #[cfg(test)] + crate::test_fonts(); let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face; let k = size / face.upem.max(1.0) as f32; text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum() diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index 016548f555..1719518bd0 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -363,6 +363,7 @@ mod tests { #[test] fn ref_link_switches() { + crate::test_fonts(); for instr in [r"REF mark\w\h", r#" REF "mark" \h"#, "REF\tmark \\H"] { assert_eq!(reference_link(instr).as_deref(), Some("#mark"), "{instr}"); } @@ -386,6 +387,7 @@ mod tests { #[test] fn page_fields() { + crate::test_fonts(); let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() }; assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true)); assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV"); @@ -480,6 +482,7 @@ mod tests { /// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored. #[test] fn ask_and_set_show_nothing() { + crate::test_fonts(); let ctx = FieldCtx::default(); assert_eq!(field_text(r#" ASK Nominativo "Inserire nome" \d "«NOME»" "#, "«NOME» «COGNOME»", &ctx), (String::new(), false)); assert_eq!(field_text(r#"SET Codice "COD""#, "COD", &ctx), (String::new(), false)); @@ -490,6 +493,7 @@ mod tests { #[test] fn page_references_show_their_bookmarks_page() { + crate::test_fonts(); let pages = HashMap::from([ ("End_1".to_string(), (3, NumFormat::Decimal)), ("Intro".to_string(), (4, NumFormat::LowerRoman)), diff --git a/crates/layout/src/kinsoku.rs b/crates/layout/src/kinsoku.rs index 432a5f826e..58c7647174 100644 --- a/crates/layout/src/kinsoku.rs +++ b/crates/layout/src/kinsoku.rs @@ -122,6 +122,7 @@ mod tests { #[test] fn auto_space_pairs() { + crate::test_fonts(); assert_eq!(auto_space('院', '9', true, true), Some(AsianSide::Left)); assert_eq!(auto_space('A', 'カ', true, true), Some(AsianSide::Right)); assert_eq!(auto_space('院', '9', true, false), None); @@ -140,6 +141,7 @@ mod tests { #[test] fn kinsoku_and_word_wrap_break_opportunities() { + crate::test_fonts(); let ja = "日本語、「漢字」。テスト"; let on = breaks(ja, true, true); for c in ["、", "」", "。", "漢"] { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 30aaa66e61..018e06b485 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -8,6 +8,18 @@ //! - [`hit`]: point ↔ position, caret geometry, line navigation, selection rectangles. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +/// Installs the pinned test fonts as the process-wide font database. The layout entry points +/// (`layout`, `layout_para`, `layout_equation`, `display::mark_text_width`) call it first in test +/// builds, so the shared `lay` helper and every test that goes through them are covered; a test +/// that looks up fonts itself before any such call (fixtures, `word::resolve`, `FontDb::global`) +/// calls it at its start. It is idempotent and never calls back into layout, so it cannot recurse. +/// The choice is process-wide and immutable, which keeps font and math caches consistent across +/// test threads. +#[cfg(test)] +fn test_fonts() { + assert!(wordcraft_fonts::FontDb::init_test_fonts(), "layout tests must initialize fixed fonts before any global font lookup"); +} + pub mod balloons; mod contour; pub mod display; @@ -312,6 +324,8 @@ pub struct DocLayout { /// Lay out the whole document. pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout { + #[cfg(test)] + crate::test_fonts(); let t0 = now_ms(); // Page totals shown in the body (`NUMPAGES`, `SECTIONPAGES`) are known only after // paginating: lay out again with the totals found until they hold (bounded). Footnotes diff --git a/crates/layout/src/math.rs b/crates/layout/src/math.rs index 95070ecd2f..b5ba3d8897 100644 --- a/crates/layout/src/math.rs +++ b/crates/layout/src/math.rs @@ -207,6 +207,8 @@ pub fn layout_equation( props: &MathProps, counter: &mut u32, ) -> MathLayout { + #[cfg(test)] + crate::test_fonts(); let parsed; let nodes: &Arg = if math.nodes.is_empty() && !math.linear { parsed = parse_linear(linear); diff --git a/crates/layout/src/math/tests.rs b/crates/layout/src/math/tests.rs index 1ba8b914f2..cf5a2f2ea9 100644 --- a/crates/layout/src/math/tests.rs +++ b/crates/layout/src/math/tests.rs @@ -65,6 +65,7 @@ fn eq_array_rows_without_ampersand_are_centred() { #[test] fn long_display_equations_wrap_before_operators_and_indent() { + crate::test_fonts(); let sum = "y=a+b+c+d+e+f+g+h+i+j+k+l+m+n+o+p+q+r+s+t+u+v+w"; let mp = MathProps::default(); let full = lay_in(&Math::default(), sum, true, 1e4, &mp); @@ -105,6 +106,7 @@ fn long_display_equations_wrap_before_operators_and_indent() { #[test] fn manual_breaks_start_new_lines() { + crate::test_fonts(); let mp = MathProps::default(); let nodes = vec![MNode::Run(MRun::new("a=b")), MNode::Run(MRun { brk: Some(0), ..MRun::new("+c") })]; let m = lay_in(&Math { nodes, ..Default::default() }, "", true, 400.0, &mp); @@ -121,6 +123,7 @@ fn manual_breaks_start_new_lines() { #[test] fn short_display_equations_stay_on_one_line() { + crate::test_fonts(); let mp = MathProps::default(); let one = lay_in(&Math::default(), "a+b=c", true, 400.0, &mp); assert_eq!(lines(&one).len(), 1); @@ -130,6 +133,7 @@ fn short_display_equations_stay_on_one_line() { #[test] fn fraction_is_stacked_with_a_rule() { + crate::test_fonts(); let flat = lay("ab", true); let f = lay("a/b", true); assert!(f.ascent > flat.ascent + 2.0 && f.descent > flat.descent + 2.0, "{f:?}"); @@ -139,6 +143,7 @@ fn fraction_is_stacked_with_a_rule() { #[test] fn superscript_is_raised_and_smaller() { + crate::test_fonts(); let m = lay("x^2", false); let sizes: Vec<(f32, f32)> = m .items @@ -154,6 +159,7 @@ fn superscript_is_raised_and_smaller() { #[test] fn operators_get_spaces_and_unary_minus_does_not() { + crate::test_fonts(); let tight = lay("ab", false); let spaced = lay("a+b", false); let unary = lay("−b", false); @@ -164,6 +170,7 @@ fn operators_get_spaces_and_unary_minus_does_not() { #[test] fn display_operators_are_big_with_limits_over_and_under() { + crate::test_fonts(); let d = lay("∑_(i=1)^n▒i", true); let t = lay("∑_(i=1)^n▒i", false); assert!(d.ascent + d.descent > t.ascent + t.descent, "display sum taller"); @@ -172,6 +179,7 @@ fn display_operators_are_big_with_limits_over_and_under() { #[test] fn delimiters_grow_around_tall_content() { + crate::test_fonts(); let small = lay("(x)", true); let big = lay("((a)/(b))", true); let h = |m: &MathLayout| m.ascent + m.descent; @@ -180,6 +188,7 @@ fn delimiters_grow_around_tall_content() { #[test] fn every_structure_lays_out() { + crate::test_fonts(); use wordcraft_doc::math::*; let x = || vec![MNode::Run(MRun::new("x"))]; let nodes = vec![ @@ -217,6 +226,7 @@ fn every_structure_lays_out() { /// drawn from a fallback font, not as a missing-glyph box. #[test] fn characters_the_math_font_lacks_fall_back() { + crate::test_fonts(); let (face, _) = wordcraft_fonts::math::resolve_math("Cambria Math"); let Some(c) = ['⨌', '⫿', '⟅', 'ア', '中', '가'].into_iter().find(|c| !face.covers(*c) && FontDb::global().fallback_for(*c, face.id()).is_some()) else { @@ -243,6 +253,7 @@ fn characters_the_math_font_lacks_fall_back() { #[test] fn caret_slots_cover_every_position() { + crate::test_fonts(); use wordcraft_doc::math_edit::{MathPos, move_right, units}; let math = Math { nodes: wordcraft_doc::math::parse_linear("x=(-b±√(b^2-4ac))/2a+■(a&b@c&d)"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); @@ -285,6 +296,7 @@ fn an_arguments_extent_spans_its_caret_positions() { #[test] fn empty_slots_show_placeholders_on_screen_only() { + crate::test_fonts(); let math = Math { nodes: wordcraft_doc::math::parse_linear("⬚/⬚"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!(m.items.iter().all(|i| matches!(i, MItem::ScreenOnly(_) | MItem::Rect { .. })), "{:?}", m.items); @@ -295,6 +307,7 @@ fn empty_slots_show_placeholders_on_screen_only() { #[test] fn numbered_equations_fill_the_line_with_the_number_at_the_right() { + crate::test_fonts(); let math = Math { nodes: wordcraft_doc::math::parse_linear("E=mc^2#(1)"), ..Default::default() }; let m = layout_equation(&math, "", &rc(), true, 400.0, &MathProps::default(), &mut 0); assert!((m.width - 400.0).abs() < 0.01, "{}", m.width); @@ -313,6 +326,7 @@ fn numbered_equations_fill_the_line_with_the_number_at_the_right() { #[test] fn math_alphabets() { + crate::test_fonts(); assert_eq!(math_alnum('x', false, true, MScr::Roman), '𝑥'); assert_eq!(math_alnum('h', false, true, MScr::Roman), 'ℎ'); assert_eq!(math_alnum('R', false, false, MScr::DoubleStruck), 'ℝ'); @@ -362,6 +376,7 @@ fn eqarr_row_without_an_align_marker_wider_than_the_columns_widens_the_array() { #[test] fn hostile_trees_never_panic() { + crate::test_fonts(); use wordcraft_doc::math::*; // Deep nesting. let mut n = vec![MNode::Run(MRun::new("x"))]; diff --git a/crates/layout/src/outline.rs b/crates/layout/src/outline.rs index a349955892..a3f4eccf3c 100644 --- a/crates/layout/src/outline.rs +++ b/crates/layout/src/outline.rs @@ -226,6 +226,7 @@ mod tests { #[test] fn show_level_and_collapse_hide_what_is_under_a_heading() { + crate::test_fonts(); // H1, body, H2, body, H1, body let levels = [1, BODY, 2, BODY, 1, BODY]; let all = OutlineView::default(); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 9c1f28f1df..44bc2f6f97 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -376,6 +376,7 @@ mod label_spacing_tests { #[test] fn fallback_label_ascent() { + crate::test_fonts(); let body = (1921.0 / 2048.0 * 11.0, 434.0 / 2048.0 * 11.0); let low = measured_label_lines(LineSpacing::Multiple(1.15), (9.802246, 2.379395), 9.802246, false, body); let tall = measured_label_lines(LineSpacing::Multiple(1.15), (11.0, 3.586), 11.0, false, body); @@ -385,6 +386,7 @@ mod label_spacing_tests { #[test] fn large_label_overflow_is_not_multiplied() { + crate::test_fonts(); for (multiple, expected) in [(1.0, 25.20), (1.25, 28.80), (2.0, 39.36)] { let lines = measured_label_lines(LineSpacing::Multiple(multiple), (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS); assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height); @@ -396,12 +398,14 @@ mod label_spacing_tests { #[test] fn small_label_descent_does_not_inflate_text_spacing() { + crate::test_fonts(); let lines = measured_label_lines(LineSpacing::Multiple(1.25), (8.499023, 3.5009766), 9.0, false, ROBOTO_METRICS); assert!((lines[0].height - 17.52).abs() <= 0.25, "{}", lines[0].height); } #[test] fn label_leading_and_earlier_font_ascent_are_retained() { + crate::test_fonts(); // Apple Symbols 24 pt: full leading gives an 18 pt label ascent. The text // pitch is still Roboto 12 pt. A shorter final fallback run must not hide it. for metrics in [(16.998047, 7.001953), (8.0, 3.0)] { @@ -412,6 +416,7 @@ mod label_spacing_tests { #[test] fn minimum_spacing_keeps_text_descent_and_label_leading() { + crate::test_fonts(); for (metrics, ascent, expected) in [((22.265625, 5.859375), 22.265625, 25.20), ((8.499023, 3.5009766), 9.0, 13.92), ((16.998047, 7.001953), 18.0, 20.88)] { @@ -422,6 +427,7 @@ mod label_spacing_tests { #[test] fn labels_keep_fixed_and_minimum_spacing_rules() { + crate::test_fonts(); for (spacing, expected) in [(LineSpacing::Exactly(24.0), 24.0), (LineSpacing::AtLeast(30.0), 30.0)] { let lines = measured_label_lines(spacing, (22.265625, 5.859375), 22.265625, false, ROBOTO_METRICS); assert!((lines[0].height - expected).abs() < 0.001); @@ -430,6 +436,7 @@ mod label_spacing_tests { #[test] fn label_overflow_only_affects_the_first_wrapped_line() { + crate::test_fonts(); let lines = measured_label_lines(LineSpacing::Multiple(1.25), (22.265625, 5.859375), 22.265625, true, ROBOTO_METRICS); assert!(lines.len() > 1); assert!((lines[0].height - 28.80).abs() <= 0.25); @@ -461,6 +468,7 @@ mod tests { #[test] fn symbols_ignore_roman_hint() { + crate::test_fonts(); let mut d = Document::new(); d.settings.roman_fonts = vec!["Symbol (PCL6)".into(), "Wingdings".into(), "No Such Roman Font".into()]; for family in ["Symbol (PCL6)", "Wingdings"] { diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index 1d024219d7..6b2605e37c 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -645,6 +645,8 @@ fn shown_text(cx: &Cx) -> Option { /// Lay out one paragraph. pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { + #[cfg(test)] + crate::test_fonts(); let doc = env.doc; let rp = paragraph_props(p, env); let res = Resolver { doc, style: p.props.style.as_deref(), table: env.table.map(|t| &t.chr) }; diff --git a/crates/layout/src/styleref.rs b/crates/layout/src/styleref.rs index 8d03623e6f..1f6e155bba 100644 --- a/crates/layout/src/styleref.rs +++ b/crates/layout/src/styleref.rs @@ -433,6 +433,7 @@ mod tests { #[test] fn parses_names_levels_and_switches() { + crate::test_fonts(); let c = parse(r#"STYLEREF "Heading 1" \l \n \t \* MERGEFORMAT"#); assert_eq!(c.style, "Heading 1"); assert!(c.last && c.suppress && !c.position); @@ -491,6 +492,7 @@ mod tests { #[test] fn suppress_keeps_the_number() { + crate::test_fonts(); assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2"); assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show"); assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number"); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 521886bdc3..9ea319127b 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -1586,6 +1586,7 @@ mod width_tests { #[test] fn autofit_shares_donor_loss() { + crate::test_fonts(); let table = Table::new(1, 4, 260.0); let mut grid = [30.0, 120.0, 20.0, 90.0]; let content = [(28.0, 28.0), (40.0, 120.0), (40.0, 40.0), (30.0, 90.0)]; @@ -1595,6 +1596,7 @@ mod width_tests { #[test] fn autofit_exhausts_donors() { + crate::test_fonts(); let table = Table::new(1, 2, 50.0); let mut grid = [30.0, 20.0]; autofit(&mut grid, &[(25.0, 25.0), (40.0, 40.0)], &table, 50.0); @@ -1603,6 +1605,7 @@ mod width_tests { #[test] fn autofit_keeps_fitting_widths() { + crate::test_fonts(); let table = Table::new(1, 3, 120.0); let mut grid = [30.0, 40.0, 50.0]; autofit(&mut grid, &[(10.0, 10.0), (20.0, 20.0), (30.0, 30.0)], &table, 120.0); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index fa7e4a111a..4015098591 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -278,6 +278,7 @@ fn legacy_table_gap(mode: u32, allow: bool, location: SpacingLocation) -> f32 { #[test] fn legacy_table_contextual_spacing_flag_keeps_after_space() { + crate::test_fonts(); // Original Word for Mac 16.113.3 probe: after 12 pt, before 0 pt, both contextual. // Zero before-space keeps the compatibility rule independent of ordinary overlap. for mode in [11, 12, 14] { @@ -288,6 +289,7 @@ fn legacy_table_contextual_spacing_flag_keeps_after_space() { #[test] fn legacy_table_contextual_spacing_flag_is_scoped_to_cells_and_legacy_mode() { + crate::test_fonts(); for (mode, allow) in [(11, false), (12, false), (14, false), (15, false), (15, true)] { assert!(legacy_table_gap(mode, allow, SpacingLocation::Cell).abs() < 0.01); } @@ -416,6 +418,7 @@ fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_ // Exactly 24 pt spacing isolates the extra gap from font-dependent line height. #[test] fn contextual_after_removes_a_smaller_before_gap() { + crate::test_fonts(); for header in [false, true] { let gap = contextual_gap(12.0, 8.0, true, false, false, header); assert!(gap.abs() < 0.01, "header={header}: gap={gap}, expected 0 pt"); @@ -424,6 +427,7 @@ fn contextual_after_removes_a_smaller_before_gap() { #[test] fn contextual_after_keeps_only_the_residual_before_gap() { + crate::test_fonts(); // ISO/IEC 29500-1 §17.3.1.9's worked example: max(10, 12) - 10 = 2 pt. for header in [false, true] { let gap = contextual_gap(10.0, 12.0, true, false, false, header); @@ -433,6 +437,7 @@ fn contextual_after_keeps_only_the_residual_before_gap() { #[test] fn contextual_spacing_preserves_each_side_and_style_identity() { + crate::test_fonts(); for header in [false, true] { for (after, before, first, second, different, expected) in [ (12.0, 8.0, false, true, false, 12.0), @@ -451,6 +456,7 @@ fn contextual_spacing_preserves_each_side_and_style_identity() { #[test] fn contextual_spacing_removes_every_gap_in_a_four_paragraph_run() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; for table in [false, true] { let mut doc = Document::new(); @@ -500,6 +506,7 @@ fn contextual_spacing_removes_every_gap_in_a_four_paragraph_run() { // Contextual and automatic list spacing can suppress the same after-space only once. #[test] fn contextual_automatic_lists_do_not_subtract_the_gap_twice() { + crate::test_fonts(); use wordcraft_doc::props::{LineSpacing, NumRef}; for table in [false, true] { let mut doc = Document::new(); @@ -548,6 +555,7 @@ fn lines_of(l: &DocLayout) -> usize { #[test] fn empty_doc_has_one_page() { + crate::test_fonts(); let d = Document::new(); let l = lay(&d); assert_eq!(l.pages.len(), 1); @@ -560,6 +568,7 @@ fn empty_doc_has_one_page() { #[test] fn hidden_marks_take_no_space() { + crate::test_fonts(); let mut d = Document::new(); d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Before", Default::default()))]; for _ in 0..20 { @@ -601,6 +610,7 @@ fn hidden_marks_take_no_space() { #[test] fn long_paragraph_wraps_and_paginates() { + crate::test_fonts(); let text = "The quick brown fox jumps over the lazy dog. ".repeat(400); let d = Document::from_text(&text); let l = lay(&d); @@ -621,6 +631,7 @@ fn long_paragraph_wraps_and_paginates() { #[test] fn reading_layout_reflows_into_viewport_height_pages_without_changing_print_layout() { + crate::test_fonts(); let d = Document::from_text(&"A moderately long sentence that should wrap across reading columns. ".repeat(250)); let opts = LayoutOptions { view: ViewMode::Read, web_width: 230.0, read_height: 390.0, ..Default::default() }; let read = layout(&d, &mut LayoutCache::new(), &opts); @@ -645,6 +656,7 @@ fn reading_layout_reflows_into_viewport_height_pages_without_changing_print_layo #[test] fn hit_and_caret_agree() { + crate::test_fonts(); let d = Document::from_text("Hello world, this is WordCraft.\nSecond paragraph here."); let l = lay(&d); for off in [0, 5, 12, 31] { @@ -663,6 +675,7 @@ fn hit_and_caret_agree() { #[test] fn selection_bar_finds_the_line_beside_the_pointer() { + crate::test_fonts(); // #856: the left margin beside a line is Word's selection bar. let text = format!("{}\nShort.", "The quick brown fox jumps over the lazy dog. ".repeat(8)); let mut d = Document::from_text(&text); @@ -726,6 +739,7 @@ fn selection_bar_of_a_right_to_left_paragraph_is_in_the_right_margin() { #[test] fn deletions_leave_the_final_text_layout() { + crate::test_fonts(); // "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED". let mut d = Document::from_text("Keep DELETEDTEXT INSERTED"); d.format_range(&Pos::body(0, 5), &Pos::body(0, 17), &|c| c.del = Some(0)).unwrap(); @@ -756,6 +770,7 @@ fn deletions_leave_the_final_text_layout() { #[test] fn deleted_soft_hyphen_is_no_hyphenation_point_without_markup() { + crate::test_fonts(); // "ab\u{ad}cdef" in a column where "ab-" fits but the whole word does not; only the soft // hyphen is track-deleted. let mut d = Document::from_text("ab\u{ad}cdef"); @@ -774,6 +789,7 @@ fn deleted_soft_hyphen_is_no_hyphenation_point_without_markup() { #[test] fn deleted_text_adds_no_line_break_opportunity_without_markup() { + crate::test_fonts(); // "xx ab\u{ad}cdef" in a column where "xx ab-" and "abcdef" fit but "xx abcdef" does not; only // the soft hyphen (5..7) is track-deleted. let mut d = Document::from_text("xx ab\u{ad}cdef"); @@ -793,6 +809,7 @@ fn deleted_text_adds_no_line_break_opportunity_without_markup() { #[test] fn hidden_soft_hyphen_is_no_hyphenation_point_when_hidden_text_is_not_shown() { + crate::test_fonts(); // As with deletions: "ab\u{ad}cdef" where only the soft hyphen is hidden text. let mut d = Document::from_text("ab\u{ad}cdef"); d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.hidden = Some(true)).unwrap(); @@ -810,6 +827,7 @@ fn hidden_soft_hyphen_is_no_hyphenation_point_when_hidden_text_is_not_shown() { #[test] fn hidden_text_adds_no_line_break_opportunity_when_not_shown() { + crate::test_fonts(); // "xx ab" + hidden soft hyphen or space + "cdef", in a column where "xx ab-" and "abcdef" fit // but "xx abcdef" does not. Measured in Word (hidden text not printed): "xx" / "abcdef" both times. for hidden in ["\u{ad}", " "] { @@ -1123,6 +1141,7 @@ fn a_word_with_alternating_advances_lays_out_in_linear_time() { #[test] fn hidden_separators_with_automatic_hyphenation_lay_out_in_linear_time() { + crate::test_fonts(); // "hyphenation " n times with every space hidden: one joined word of 11n letters reaches the // hyphenator, whose limit checks once rescanned the word for every candidate point. let doc = |n: usize| { @@ -1161,6 +1180,7 @@ fn a_document_of_many_paragraphs_lays_out_in_linear_time() { #[test] fn many_hidden_float_anchors_lay_out_in_linear_time() { + crate::test_fonts(); // n square-wrapped shapes, each anchored in its own hidden run (bold alternating, so runs do not // merge), markup on: finding each anchor's formatting once rescanned the runs from the start. let doc = |n: usize| { @@ -1215,6 +1235,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() { #[test] fn fragmented_hidden_text_lays_out_like_the_visible_text() { + crate::test_fonts(); // Visible words with a hidden multi-byte char after every visible one, formatted in alternating // runs (bold on and off) so no two left-out runs are adjacent or merge. The plain paragraph has // the same visible runs without the hidden chars, so both lay out the same widths whatever font @@ -1269,6 +1290,7 @@ fn fragmented_hidden_text_lays_out_like_the_visible_text() { #[test] fn automatic_hyphenation_sees_only_the_text_laid_out() { + crate::test_fonts(); // Paragraph-text ends of the clusters after which a line may end with a hyphen. let hyph_ends = |d: &Document, opts: &LayoutOptions| { let l = layout(d, &mut LayoutCache::new(), opts); @@ -1308,6 +1330,7 @@ fn automatic_hyphenation_sees_only_the_text_laid_out() { #[test] fn page_break_char_starts_new_page() { + crate::test_fonts(); let mut d = Document::from_text("one"); d.insert_text(&Pos::body(0, 3), "\u{000C}", &Default::default()).unwrap(); let l = lay(&d); @@ -1319,6 +1342,7 @@ fn page_break_char_starts_new_page() { /// beating `w:default`); a drop-down shows its selected entry whatever result was saved. #[test] fn legacy_form_fields_are_drawn() { + crate::test_fonts(); let field = |form: &str| InlineObject::Field { instr: "FORMCHECKBOX".into(), result: String::new(), @@ -1378,6 +1402,7 @@ fn legacy_form_fields_are_drawn() { #[test] fn a_missing_roman_font_is_drawn_as_word_draws_it() { + crate::test_fonts(); // Measured in Word: a font that isn't installed is drawn in Cambria when the font table calls // it roman, in Calibri otherwise. let face = |roman: bool| { @@ -1399,6 +1424,7 @@ fn a_missing_roman_font_is_drawn_as_word_draws_it() { #[test] fn a_missing_roman_font_with_its_own_substitute_keeps_it() { + crate::test_fonts(); // Times New Roman is roman in every font table. Where it isn't installed it keeps its metric // clone (Liberation Serif), not Cambria: Word, which always has it, never draws it missing. let mut d = Document::from_text("Serif"); @@ -1413,6 +1439,7 @@ fn a_missing_roman_font_with_its_own_substitute_keeps_it() { #[test] fn an_empty_section_mark_stays_on_a_full_page() { + crate::test_fonts(); use wordcraft_doc::section::SectionProps; // Measured in Word: a page filled to the bottom, then an empty paragraph carrying a next-page // section break, makes no page for the mark; the next section starts on page 2. A plain empty @@ -1436,6 +1463,7 @@ fn an_empty_section_mark_stays_on_a_full_page() { #[test] fn a_bookmark_after_a_page_break_takes_no_line() { + crate::test_fonts(); // "Before", a page break, then a bookmark start (Word writes the next heading's TOC bookmark // there): the bookmark takes no room, so "After" starts at the top of page 2, as in Word. let top_of_after = |bookmark: bool| { @@ -1458,6 +1486,7 @@ fn a_bookmark_after_a_page_break_takes_no_line() { #[test] fn a_page_break_then_a_new_page_makes_one_page() { + crate::test_fonts(); use wordcraft_doc::section::SectionProps; // "Before", a paragraph of only a page break, then "After" starting a page by itself: a page // break before, or a next-page section break carried by the break's own paragraph. Word's @@ -1480,6 +1509,7 @@ fn a_page_break_then_a_new_page_makes_one_page() { #[test] fn page_numbers_can_restart_at_zero() { + crate::test_fonts(); // `w:pgNumType w:start="0"`: a cover page numbered 0, so the page after it reads 1. let mut d = Document::from_text("Cover\u{000C}Contents"); d.last_section.page_num_start = Some(0); @@ -1489,6 +1519,7 @@ fn page_numbers_can_restart_at_zero() { #[test] fn center_and_right_alignment() { + crate::test_fonts(); let mut d = Document::from_text("abc\nabc\nabc"); d.format_paragraphs(&Pos::body(1, 0), &Pos::body(1, 0), &|p| p.align = Some(Align::Center)).unwrap(); d.format_paragraphs(&Pos::body(2, 0), &Pos::body(2, 0), &|p| p.align = Some(Align::Right)).unwrap(); @@ -1529,6 +1560,7 @@ fn caret_on_an_empty_line_after_a_break_follows_alignment() { #[test] fn tabs_land_on_default_stops() { + crate::test_fonts(); let d = Document::from_text("a\tb"); let l = lay(&d); let xb = l.caret(&Pos::body(0, 2)).unwrap().x; @@ -1542,6 +1574,7 @@ fn tabs_land_on_default_stops() { /// Letterheads that line up a footer with tabs under a negative indent rely on it. #[test] fn tabs_left_of_the_margin_stay_on_a_default_stop() { + crate::test_fonts(); let x_of = |text: &str, indent: f32, off: usize| { let mut d = Document::from_text(text); d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.indent_left = Some(indent)).unwrap(); @@ -1556,6 +1589,7 @@ fn tabs_left_of_the_margin_stay_on_a_default_stop() { #[test] fn right_tab_aligns_text_end() { + crate::test_fonts(); let mut d = Document::from_text("left\tright"); d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| { p.tabs = Some(vec![wordcraft_doc::props::TabStop { pos: 400.0, align: wordcraft_doc::props::TabAlign::Right, leader: Default::default() }]) @@ -1620,6 +1654,7 @@ fn ptab(align: wordcraft_doc::para::PTabAlign, relative_to: wordcraft_doc::para: /// the gap. #[test] fn right_positional_tab_ends_text_on_the_right_margin() { + crate::test_fonts(); use wordcraft_doc::para::{PTabAlign, PTabBase}; let d = ptab_doc("Left", "Right", ptab(PTabAlign::Right, PTabBase::Margin, wordcraft_doc::props::TabLeader::Dot), (0.0, 36.0)); let l = lay(&d); @@ -1633,6 +1668,7 @@ fn right_positional_tab_ends_text_on_the_right_margin() { /// and right indents, not between the margins. #[test] fn center_positional_tab_relative_to_indents_centres_between_them() { + crate::test_fonts(); use wordcraft_doc::para::{PTabAlign, PTabBase}; let d = ptab_doc("a", "Mid", ptab(PTabAlign::Center, PTabBase::Indent, wordcraft_doc::props::TabLeader::None), (36.0, 108.0)); let l = lay(&d); @@ -1646,6 +1682,7 @@ fn center_positional_tab_relative_to_indents_centres_between_them() { /// that place. #[test] fn positional_tab_behind_the_text_moves_to_the_next_line() { + crate::test_fonts(); use wordcraft_doc::para::{PTabAlign, PTabBase}; let before = "word ".repeat(14); let d = ptab_doc(&before, "X", ptab(PTabAlign::Left, PTabBase::Margin, wordcraft_doc::props::TabLeader::None), (0.0, 0.0)); @@ -1660,6 +1697,7 @@ fn positional_tab_behind_the_text_moves_to_the_next_line() { /// doesn't leave it blank to go to the next line, even when its place is behind the indent. #[test] fn positional_tab_after_only_a_bookmark_stays_on_its_line() { + crate::test_fonts(); use wordcraft_doc::para::{PTabAlign, PTabBase}; let mut d = ptab_doc("", "X", ptab(PTabAlign::Left, PTabBase::Margin, wordcraft_doc::props::TabLeader::None), (36.0, 0.0)); d.insert_object(&Pos::body(0, 0), InlineObject::BookmarkStart { name: "top".into() }, &Default::default()).unwrap(); @@ -1672,6 +1710,7 @@ fn positional_tab_after_only_a_bookmark_stays_on_its_line() { #[test] fn narrow_cells_keep_their_width() { + crate::test_fonts(); let mut p = wordcraft_doc::Paragraph::with_text("WW", wordcraft_doc::CharProps { size: Some(6.0), ..Default::default() }); p.props.space_before = Some(0.0); p.props.space_after = Some(0.0); @@ -1884,6 +1923,7 @@ fn a_bookmark_is_no_line_break_opportunity() { #[test] fn lists_get_labels_and_indent() { + crate::test_fonts(); let mut d = Document::from_text("one\ntwo\nthree"); let id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered); d.format_paragraphs(&Pos::body(0, 0), &Pos::body(2, 5), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); @@ -1924,6 +1964,7 @@ fn a_none_format_label_shows_its_text_and_tabs_to_the_indent() { #[test] fn break_only_list_has_no_label() { + crate::test_fonts(); use wordcraft_doc::ListKind; for kind in [ListKind::Numbered, ListKind::Bullet] { let mut d = Document::from_text("First\n\u{000C}\nLast\n\nAfter"); @@ -2012,6 +2053,7 @@ fn empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing() { #[test] fn a_numbered_heading_opening_with_a_page_break_takes_its_number_along() { + crate::test_fonts(); // "Before", then a numbered paragraph whose text opens with a page break: Word draws its // number on the next page with the text, not alone at the bottom of the first. let mut d = Document::from_text("Before\n\u{000C}Heading"); @@ -2032,6 +2074,7 @@ fn a_numbered_heading_opening_with_a_page_break_takes_its_number_along() { #[test] fn russian_letter_list_labels_draw_cyrillic() { + crate::test_fonts(); // russianLower labelled items 1, 2, 3 instead of а), б), в). use wordcraft_doc::section::NumFormat; let mut d = Document::from_text("один\nдва\nтри"); @@ -2063,6 +2106,7 @@ fn russian_letter_list_labels_draw_cyrillic() { #[test] fn chinese_counting_list_labels_and_their_font_runs() { + crate::test_fonts(); // Issue #534: a "一、" list showed digits, drawn with glyph ids of another font. use wordcraft_doc::section::NumFormat; let mut d = Document::from_text("甲\n乙\n丙"); @@ -2112,6 +2156,7 @@ fn chinese_counting_list_labels_and_their_font_runs() { #[test] fn symbol_font_bullet_without_the_font_draws_a_bullet() { + crate::test_fonts(); // Word's default bullet: U+F0B7 in Symbol. let mut d = Document::from_text("one"); let id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::BulletChar('\u{F0B7}')); @@ -2149,6 +2194,7 @@ fn bullet_first_line(label_font: Option<&str>) -> (f32, f32, f32, f32) { #[test] fn a_symbol_bullet_sizes_its_line_by_the_symbol_font() { + crate::test_fonts(); // Measured in Word: a Symbol bullet makes its line taller than the text alone (Calibri 11 pt // at 1.08 spacing: 15.1 pt, not 14.5 pt), the extra ascent added unscaled. Symbol isn't an open // font; without it the bullet is drawn from another face but its line keeps Symbol's height. @@ -2174,6 +2220,7 @@ fn a_pcl6_symbol_bullet_sizes_its_line_as_symbol() { #[test] fn pcl6_symbol_draws_greater_equal() { + crate::test_fonts(); let mut d = Document::from_text("\u{F0B3}"); d.format_range(&Pos::body(0, 0), &Pos::body(0, 3), &|c| c.font = Some("Symbol (PCL6)".into())).unwrap(); let l = lay(&d); @@ -2185,6 +2232,7 @@ fn pcl6_symbol_draws_greater_equal() { #[test] fn private_use_bullet_no_font_draws_shows_the_levels_default_bullet() { + crate::test_fonts(); // #1683: a level-2 bullet stored as a private-use code in a font that isn't installed was // drawn from an installed icon font (U+F081 is the Twitter logo in Font Awesome). let mut d = Document::from_text("one"); @@ -2204,6 +2252,7 @@ fn private_use_bullet_no_font_draws_shows_the_levels_default_bullet() { #[test] fn wingdings_text_draws_and_reads_as_its_unicode_symbols() { + crate::test_fonts(); // Word stores symbol-font text both ways: plain codes ("J" = smiley) and private-use codes // (U+F0FC = check mark, what `w:sym` writes). let mut d = Document::from_text("Hi J\u{F0FC} ok"); @@ -2235,6 +2284,7 @@ fn wingdings_text_draws_and_reads_as_its_unicode_symbols() { #[test] fn tables_lay_out_cells() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(2, 3, 468.0); t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("cell text", Default::default()))]; @@ -2276,6 +2326,7 @@ fn a_tables_own_space_above_and_below_moves_the_text() { /// continuation row's box is `covered` (no gridline, not a hit). #[test] fn a_vertical_merge_lays_out_as_one_cell() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(3, 2, 468.0); t.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart; @@ -2318,6 +2369,7 @@ fn turned_lines(l: &DocLayout) -> &Placed { /// grows to fit it, and caret, clicks and drawing follow the turn. #[test] fn turned_cell_text_runs_down_the_cell() { + crate::test_fonts(); let text = "Turned cell text"; let d = turned_table(TextDirection::Down, text, None); let l = lay(&d); @@ -2356,6 +2408,7 @@ fn turned_cell_text_runs_down_the_cell() { #[test] fn turned_up_cell_text_reads_bottom_to_top() { + crate::test_fonts(); let text = "Bottom to top"; let d = turned_table(TextDirection::Up, text, None); let l = lay(&d); @@ -2527,6 +2580,7 @@ fn a_click_by_a_floating_table_with_no_upright_text_beside_it_prefers_turned_tex #[test] fn turned_text_wraps_in_an_exact_row() { + crate::test_fonts(); let d = turned_table(TextDirection::Down, &"word ".repeat(30), Some(72.0)); let l = lay(&d); let it = turned_lines(&l); @@ -2540,6 +2594,7 @@ fn turned_text_wraps_in_an_exact_row() { #[test] fn hostile_turned_cells_never_panic() { + crate::test_fonts(); // Empty, huge and nested turned cells, in exact rows too small for anything. for dir in [TextDirection::Down, TextDirection::Up] { for row in [None, Some(0.5), Some(1e9)] { @@ -2567,6 +2622,7 @@ fn rules_on_page0(l: &DocLayout) -> Vec { #[test] fn table_style_borders_survive_empty_tbl_borders() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(2, 2, 468.0); // defaults to the TableGrid style t.props.borders = Some(Borders::default()); // what an empty parses to @@ -2584,6 +2640,7 @@ fn table_style_borders_survive_empty_tbl_borders() { /// vertical edge it meets, not at its centre line. #[test] fn table_border_corners_close() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(2, 2, 468.0); let thick = Some(Border::single(3.0)); @@ -2750,6 +2807,7 @@ fn grid_before_rows_stay_shifted_across_pages() { #[test] fn table_own_border_overrides_only_that_side() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(2, 2, 468.0); t.props.borders = Some(Borders { top: Some(Border::single(2.0)), ..Default::default() }); @@ -2762,6 +2820,7 @@ fn table_own_border_overrides_only_that_side() { #[test] fn table_nil_border_hides_style_side() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(1, 1, 468.0); // 1×1: no inside edges t.props.borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })); @@ -2771,6 +2830,7 @@ fn table_nil_border_hides_style_side() { #[test] fn table_without_style_stays_borderless() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(2, 2, 468.0); t.props.style = None; @@ -2781,6 +2841,7 @@ fn table_without_style_stays_borderless() { #[test] fn headers_and_page_fields() { + crate::test_fonts(); let mut d = Document::from_text(&"para\n".repeat(120)); let mut hp = wordcraft_doc::Paragraph::new(); hp.insert_object( @@ -2808,6 +2869,7 @@ fn headers_and_page_fields() { /// typed after them wraps to the next line. Same in the body and in a header. #[test] fn caret_after_hanging_spaces_stays_at_the_margin() { + crate::test_fonts(); let spaces = format!("a{}", " ".repeat(200)); for header in [false, true] { for text in [spaces.clone(), format!("{spaces}b")] { @@ -2838,6 +2900,7 @@ fn caret_after_hanging_spaces_stays_at_the_margin() { #[test] fn tall_first_page_header_pushes_body_down() { + crate::test_fonts(); let mut d = Document::from_text(&"para\n".repeat(120)); let tall: Vec<_> = (0..12).map(|_| wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())).collect(); let id = d.add_part(wordcraft_doc::PartKind::Header, tall); @@ -2854,6 +2917,7 @@ fn tall_first_page_header_pushes_body_down() { #[test] fn cache_reuses_unchanged_paragraphs() { + crate::test_fonts(); let d = Document::from_text(&"some text here\n".repeat(50)); let mut c = LayoutCache::new(); layout(&d, &mut c, &LayoutOptions::default()); @@ -2893,6 +2957,7 @@ fn kept_headings_measure_the_next_paragraph_through_the_cache() { /// again after an edit elsewhere measures nothing again. #[test] fn cache_keeps_one_layout_per_table_cell() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(4, 3, 468.0); for (r, row) in t.rows.iter_mut().enumerate() { @@ -2913,6 +2978,7 @@ fn cache_keeps_one_layout_per_table_cell() { #[test] fn selection_rects_cover_range() { + crate::test_fonts(); let d = Document::from_text("Hello world\nSecond line"); let l = lay(&d); let r = l.selection_rects(&d, &Pos::body(0, 6), &Pos::body(1, 6), 0); @@ -2922,6 +2988,7 @@ fn selection_rects_cover_range() { #[test] fn column_segments_take_the_same_x_range_on_every_line() { + crate::test_fonts(); let d = Document::from_text("abcdef\nabcdef\nab"); let l = lay(&d); let left = l.caret(&Pos::body(0, 2)).unwrap().x; @@ -2937,6 +3004,7 @@ fn column_segments_take_the_same_x_range_on_every_line() { #[test] fn display_has_glyphs_and_marks() { + crate::test_fonts(); let mut d = Document::from_text("Hello\tworld"); d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.underline = Some(wordcraft_doc::props::Underline::Single)).unwrap(); let l = lay(&d); @@ -2949,6 +3017,7 @@ fn display_has_glyphs_and_marks() { #[test] fn a_tracked_paragraph_mark_shows_in_its_authors_colour() { + crate::test_fonts(); let mut d = Document::from_text("Split\nhere"); d.revisions.push(wordcraft_doc::Revision { kind: wordcraft_doc::RevisionKind::Insert, @@ -2970,6 +3039,7 @@ fn a_tracked_paragraph_mark_shows_in_its_authors_colour() { #[test] fn section_breaks_show_with_formatting_marks() { + crate::test_fonts(); use wordcraft_doc::section::{SectionProps, SectionStart}; // Two sections: "One" ends the first; the second starts as `start`. let doc = |start: SectionStart| { @@ -3133,6 +3203,7 @@ fn side_borders_lie_beyond_their_distance_from_the_text() { #[test] fn space_between_paragraphs_is_the_larger_of_after_and_before() { + crate::test_fonts(); // (space after the first, space before the second, contextual spacing on both) -> gap let gap = |after: f32, before: f32, contextual: bool| -> f32 { let mut d = Document::from_text("One\nTwo"); @@ -3157,6 +3228,7 @@ fn space_between_paragraphs_is_the_larger_of_after_and_before() { #[test] fn space_between_paragraphs_is_their_sum_when_html_auto_spacing_is_off() { + crate::test_fonts(); // Measured in Word (compatibility modes 14 and 15): with `w:doNotUseHTMLParagraphAutoSpacing`, // 12 pt after and 12 pt before leave 24 pt; without it, 12 pt. let gaps = |sum: bool| -> (f32, f32) { @@ -3190,6 +3262,7 @@ fn space_between_paragraphs_is_their_sum_when_html_auto_spacing_is_off() { #[test] fn space_between_paragraphs_in_a_cell_follows_the_body_rule() { + crate::test_fonts(); // Three paragraphs in a cell, each 6pt before and 6pt after -> the gaps between them. let gaps = |contextual: bool| -> Vec { let mut d = Document::from_text("before\nafter"); @@ -3223,6 +3296,7 @@ fn space_between_paragraphs_in_a_cell_follows_the_body_rule() { #[test] fn sections_land_on_the_side_their_break_and_numbering_ask_for() { + crate::test_fonts(); use wordcraft_doc::section::{SectionProps, SectionStart}; // "One" ends the first section on page 1; the second section starts as `start`. let pages = |start: SectionStart, restart: Option, even_odd: bool| -> Vec<(usize, u32)> { @@ -3288,6 +3362,7 @@ fn a_section_starting_a_page_flushes_the_page_before_with_its_own_margins() { #[test] fn paragraph_marks_sit_past_text_running_the_other_way() { + crate::test_fonts(); // A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the // logically last letters are at the wrong edge, but the ¶ still goes past every glyph. let mut d = Document::from_text("Hello \u{5e9}\u{5dc}\u{5d5}\u{5dd}\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc"); @@ -3328,6 +3403,7 @@ fn border_lines_in(d: &Document, l: &DocLayout) -> Vec<(f32, f32, f32, f32)> { #[test] fn character_border_draws_one_box_around_run() { + crate::test_fonts(); use wordcraft_doc::props::Border; let mut d = Document::from_text("Hello world"); assert!(border_lines(&d).is_empty()); @@ -3349,6 +3425,7 @@ fn character_border_draws_one_box_around_run() { #[test] fn equal_adjacent_borders_share_a_box() { + crate::test_fonts(); use wordcraft_doc::props::Border; let mut d = Document::from_text("ab"); d.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.border = Some(Border::single(0.5))).unwrap(); @@ -3360,6 +3437,7 @@ fn equal_adjacent_borders_share_a_box() { #[test] fn wrapped_border_boxes_each_line() { + crate::test_fonts(); use wordcraft_doc::props::Border; let text = "boxed ".repeat(20); let mut d = Document::from_text(text.trim_end()); @@ -3371,6 +3449,7 @@ fn wrapped_border_boxes_each_line() { #[test] fn runs_differing_only_in_link_or_decoration_keep_their_own_style() { + crate::test_fonts(); let mut d = Document::from_text("Alpha Beta Gamma Delta"); let set = |d: &mut Document, a: usize, b: usize, f: &dyn Fn(&mut wordcraft_doc::props::CharProps)| { d.format_range(&Pos::body(0, a), &Pos::body(0, b), f).unwrap(); @@ -3407,6 +3486,7 @@ fn runs_differing_only_in_link_or_decoration_keep_their_own_style() { #[test] fn web_view_is_one_page() { + crate::test_fonts(); let d = Document::from_text(&"text ".repeat(3000)); let mut c = LayoutCache::new(); let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, ..Default::default() }); @@ -3416,6 +3496,7 @@ fn web_view_is_one_page() { #[test] fn hostile_props_do_not_panic() { + crate::test_fonts(); let mut d = Document::from_text("x\ny"); d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p| { *p = ParaProps { indent_left: Some(1e9), indent_right: Some(1e9), indent_first: Some(-1e9), ..Default::default() } @@ -3429,6 +3510,7 @@ fn hostile_props_do_not_panic() { #[test] fn layout_is_fast() { + crate::test_fonts(); let d = Document::from_text(&"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.\n".repeat(3000)); let mut c = LayoutCache::new(); @@ -3446,6 +3528,7 @@ fn layout_is_fast() { #[test] fn empty_center_tab_then_right_tab() { + crate::test_fonts(); let mut d = Document::from_text("left\t\tright"); d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.style = Some("Header".into())).unwrap(); let l = lay(&d); @@ -3455,6 +3538,7 @@ fn empty_center_tab_then_right_tab() { #[test] fn footnotes_sit_at_page_bottom() { + crate::test_fonts(); let mut d = Document::from_text(&"Body text line.\n".repeat(30)); let mut note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default()).styled("FootnoteText"); note.props.space_after = Some(0.0); @@ -3490,6 +3574,7 @@ fn footnotes_sit_at_page_bottom() { #[test] fn deleted_note_anchors_take_no_number_and_print_no_note() { + crate::test_fonts(); use wordcraft_doc::para::NoteKind; let mut d = Document::from_text("Alpha Beta Gamma"); let mut ids = Vec::new(); @@ -3536,6 +3621,7 @@ fn deleted_note_anchors_take_no_number_and_print_no_note() { #[test] fn hidden_note_anchors_keep_their_number_and_their_note() { + crate::test_fonts(); // Measured in Word (hidden text not printed): a hidden footnote or endnote reference mark still // takes its number, so the visible footnote after it is 2, and both notes are still printed. use wordcraft_doc::para::NoteKind; @@ -3582,6 +3668,7 @@ fn hidden_note_anchors_keep_their_number_and_their_note() { #[test] fn text_wraps_around_square_float() { + crate::test_fonts(); let mut d = Document::from_text(&"Words flow around the picture here. ".repeat(30)); let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, @@ -3663,6 +3750,7 @@ fn a_lines_arrowheads_sit_at_its_ends() { #[test] fn freeform_arrow_end_draws_at_the_last_point() { + crate::test_fonts(); use std::sync::Arc; use wordcraft_doc::connector::ShapeExtra; use wordcraft_doc::freeform::{FreePath, Freeform}; @@ -3763,6 +3851,7 @@ fn an_arrowhead_is_as_long_and_wide_as_its_sizes_make_it() { #[test] fn hidden_drawings_are_neither_drawn_nor_take_room() { + crate::test_fonts(); // A hidden floating shape (VML `visibility:hidden`, `wp:docPr/@hidden`): no wrap area, nothing // drawn, as in Word for Windows. A hidden inline one takes no room in its line. let mut d = Document::from_text(&"Words flow around the picture here. ".repeat(30)); @@ -3813,6 +3902,7 @@ fn hidden_drawings_are_neither_drawn_nor_take_room() { #[test] fn deleted_float_leaves_no_wrap_area_without_markup() { + crate::test_fonts(); // A square-wrapped shape anchored at the start of the paragraph, its anchor track-deleted. let text = "Words flow around the picture here. ".repeat(30); let mut d = Document::from_text(&text); @@ -3859,6 +3949,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() { #[test] fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() { + crate::test_fonts(); // A square-wrapped shape whose anchor is hidden text, through a character style (so only the // resolved formatting says so). Measured in Word: not printed, and the text runs full width. let text = "Words flow around the picture here. ".repeat(30); @@ -3915,6 +4006,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() { /// overflowed when the next page or line was counted: a panic with overflow checks on. #[test] fn extreme_page_and_line_number_starts_do_not_overflow() { + crate::test_fonts(); let mut d = Document::from_text("one\ntwo\nthree"); for b in d.body.iter_mut() { if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { @@ -3963,6 +4055,7 @@ fn line_numbers_follow_the_line_number_style() { #[test] fn line_numbers_borders_text_boxes() { + crate::test_fonts(); let mut d = Document::from_text("one\ntwo\nthree"); d.last_section.line_numbers = Some(Default::default()); d.last_section.page_borders = Some(wordcraft_doc::props::Borders::box_(wordcraft_doc::props::Border::single(1.0))); @@ -3994,6 +4087,7 @@ fn line_numbers_borders_text_boxes() { #[test] fn tall_rows_split_across_pages() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(2, 2, 468.0); let long = "Row text that keeps going and going so the cell grows taller than a page. ".repeat(120); @@ -4100,6 +4194,7 @@ fn cell_line_bottoms(paras: &[(&str, f32)], i: usize) -> Vec { /// cell paragraph breaks 6 + 2). It used to move the whole row to the next page. #[test] fn a_split_row_keeps_its_bottom_margin_and_widow_control() { + crate::test_fonts(); let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); let paras = [(text.as_str(), 0.0)]; let bottoms = cell_line_bottoms(¶s, 0); @@ -4127,6 +4222,7 @@ fn a_split_row_keeps_its_bottom_margin_and_widow_control() { /// as usual. #[test] fn a_cell_emptied_by_widow_control_takes_its_row_to_the_next_page() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; // Letter, 1 inch margins: 648 pt of text. `filler` lines of exactly 14 pt, then a row whose // second cell holds one paragraph of `lines` lines, 14 pt each. @@ -4217,6 +4313,7 @@ fn row_content_pieces(text: &str, props: ParaProps, room: f32) -> Vec<(usize, us #[test] fn row_split_ignores_afterspace() { + crate::test_fonts(); let two = ["Top", "Bottom"].join(&wordcraft_doc::para::LINE_BREAK.to_string()); for (text, room, expected) in [("Label", 18.0, vec![(0, 1), (1, 3)]), (two.as_str(), 32.0, vec![(0, 2), (2, 2)])] { let props = ParaProps { space_after: Some(8.0), widow_control: Some(true), ..Default::default() }; @@ -4226,6 +4323,7 @@ fn row_split_ignores_afterspace() { #[test] fn row_split_respects_widows() { + crate::test_fonts(); let two = ["Top", "Bottom"].join(&wordcraft_doc::para::LINE_BREAK.to_string()); let three = ["Top", "Middle", "Bottom"].join(&wordcraft_doc::para::LINE_BREAK.to_string()); for (text, widow, room, expected) in [ @@ -4244,6 +4342,7 @@ fn row_split_respects_widows() { /// (Word keeps the "Remarks:" line on the page) instead of moving whole. #[test] fn a_split_row_keeps_the_last_paragraphs_space_after() { + crate::test_fonts(); let paras = [("Remarks:", 0.0), ("", 8.0)]; let bottom = cell_line_bottoms(¶s, 1).last().copied().unwrap_or(0.0); // The empty paragraph's line ends 6pt above the page bottom (720): 8pt after + 4pt margin @@ -4258,6 +4357,7 @@ fn a_split_row_keeps_the_last_paragraphs_space_after() { /// continuation is as tall as the cell needing the most room. #[test] fn split_row_cells_each_continue_at_the_top() { + crate::test_fonts(); let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); let row = |push: f32, singles: usize| { let mut d = Document::from_text("Filler"); @@ -4318,6 +4418,7 @@ fn split_row_cells_each_continue_at_the_top() { /// room is less than its minimum height. #[test] fn exact_rows_move_on_whole() { + crate::test_fonts(); let text = "Dotted leader lines to fill in by hand later on. ".repeat(14); let lay_row = |rule: wordcraft_doc::props::HeightRule| { let mut d = Document::from_text("Filler"); @@ -4358,6 +4459,7 @@ fn exact_rows_move_on_whole() { /// which squashed the nested cell to 2pt on the next page. #[test] fn a_nested_cell_cut_by_the_page_bottom_keeps_its_rest() { + crate::test_fonts(); let mut d = Document::from_text("Filler"); if let Some(wordcraft_doc::Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) { p.props.space_before = Some(400.0); @@ -4405,6 +4507,7 @@ fn a_nested_cell_cut_by_the_page_bottom_keeps_its_rest() { /// behaviour). #[test] fn a_split_at_least_row_keeps_its_height() { + crate::test_fonts(); let mut d = Document::from_text("Filler"); let mut t = Table::new(1, 1, 400.0); t.rows[0].props.height = Some(1200.0); @@ -4429,6 +4532,7 @@ fn a_split_at_least_row_keeps_its_height() { /// on a NaN cut). #[test] fn split_row_rejects_a_hostile_cut() { + crate::test_fonts(); use crate::table::{CellPart, RowLayout, split_row}; let d = Document::from_text("Line"); let l = lay(&d); @@ -4462,6 +4566,7 @@ fn split_row_rejects_a_hostile_cut() { #[test] fn repeated_header_is_followed_by_the_next_row_even_when_it_overflows() { + crate::test_fonts(); // A one-column table at the top of a letter page (body 648 pt), then an empty paragraph. Returns // the table rows on each page, in order. let rows_per_page = |t: Table| -> Vec> { @@ -4509,6 +4614,7 @@ fn repeated_header_is_followed_by_the_next_row_even_when_it_overflows() { #[test] fn repeated_header_moves_with_the_first_row_that_does_not_fit() { + crate::test_fonts(); // A paragraph that leaves `room` points of the letter page's 648 pt body, then a table with a // 20 pt header row and 40 pt one-line rows. Returns the table rows on each page. let rows_per_page = |room: f32| -> Vec> { @@ -4659,6 +4765,7 @@ fn a_repeated_header_is_never_left_alone_when_the_table_starts_mid_page() { /// A drop-cap paragraph in a font larger than 1000 pt used to panic in layout (`clamp` with min > max). #[test] fn drop_cap_with_a_huge_font_lays_out() { + crate::test_fonts(); for size in [999.0, 1000.0, 1001.0, 1638.0] { let cp = wordcraft_doc::CharProps { size: Some(size), ..Default::default() }; let mut p = wordcraft_doc::Paragraph::with_text("Hello drop cap world", cp); @@ -4676,6 +4783,7 @@ fn drop_cap_with_a_huge_font_lays_out() { #[test] fn drop_cap_indents_its_lines() { + crate::test_fonts(); let mut p = wordcraft_doc::Paragraph::with_text(&"Every line of this paragraph wraps around a large first letter. ".repeat(6), Default::default()); p.props.drop_cap = Some(3); @@ -4722,6 +4830,7 @@ fn drop_cap_layout(set: impl Fn(&mut wordcraft_doc::props::ParaProps)) -> std::s /// letter left of it, the distance away; the letter takes the chosen font. #[test] fn drop_cap_options_place_the_letter() { + crate::test_fonts(); let x0 = |pl: &crate::para::ParaLayout, k: usize| pl.lines.get(k).and_then(|l| l.xs.first().copied()).unwrap_or(f32::NAN); let letter_x = |pl: &crate::para::ParaLayout| x0(pl, 0) + pl.glyphs.first().map(|g| g.dx).unwrap_or(f32::NAN); @@ -4796,6 +4905,7 @@ fn drop_cap_font_covers_scripts_and_alignment_keeps_the_gap() { #[test] fn auto_hyphenation_breaks_long_words() { + crate::test_fonts(); let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4); let mut d = Document::from_text(&text); let off = lay(&d); @@ -4829,6 +4939,7 @@ fn auto_hyphenation_breaks_long_words() { /// with a hyphen, and words in capitals stay whole when the document says so. #[test] fn hyphenation_options_limit_hyphens_and_spare_capitals() { + crate::test_fonts(); let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(6); let lines = |d: &Document| -> Vec { lay(d).pages[0] @@ -4883,6 +4994,7 @@ fn cell_para(l: &DocLayout, row: u32, col: u32) -> Arc { /// 8 pt after and 1.15 lines. #[test] fn table_style_paragraph_props_beat_document_defaults() { + crate::test_fonts(); let d = styled_table_doc(Some("TableGrid"), 3); let plain = styled_table_doc(None, 3); let (lg, lp) = (lay(&d), lay(&plain)); @@ -4930,6 +5042,7 @@ fn row_bands_shade_only_with_a_band_size() { /// table style only fills in what they leave unset. #[test] fn paragraph_style_and_direct_formatting_beat_table_style() { + crate::test_fonts(); let mut d = styled_table_doc(Some("TableGrid"), 3); Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style { id: "Spaced".into(), @@ -4967,6 +5080,7 @@ fn paragraph_style_and_direct_formatting_beat_table_style() { /// adds its own run formatting and conditional formats. #[test] fn derived_table_style_merges_its_base() { + crate::test_fonts(); use wordcraft_doc::styles::TableStyleParts; let red = wordcraft_doc::Rgb(0xC0, 0, 0); let mut d = styled_table_doc(Some("RedGrid"), 3); @@ -5072,6 +5186,7 @@ fn box_rect(l: &DocLayout, id: u32) -> wordcraft_geom::Rect { #[test] fn clicks_inside_a_text_box_hit_its_story() { + crate::test_fonts(); let mut d = Document::from_text("Before after"); let id = text_box(&mut d, 7, "Inside the box", 144.0, 72.0, Default::default()); let l = lay(&d); @@ -5095,6 +5210,7 @@ fn clicks_inside_a_text_box_hit_its_story() { #[test] fn body_text_wins_over_a_text_box_behind_it() { + crate::test_fonts(); let mut d = Document::from_text("Hi"); let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::BehindText, @@ -5116,6 +5232,7 @@ fn body_text_wins_over_a_text_box_behind_it() { #[test] fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() { + crate::test_fonts(); let mut d = Document::from_text("Words before the objects."); let id = text_box(&mut d, 0, "Inside", 144.0, 72.0, Default::default()); let shape = InlineObject::Shape { @@ -5228,6 +5345,7 @@ fn box_lines(l: &DocLayout, id: u32) -> (usize, usize, wordcraft_geom::Rect) { #[test] fn text_box_hides_text_that_does_not_fit() { + crate::test_fonts(); let long = "The quick brown fox jumps over the lazy dog. ".repeat(12); let mut d = Document::from_text("Body"); let id = text_box(&mut d, 0, &long, 144.0, 72.0, Default::default()); @@ -5248,6 +5366,7 @@ fn text_box_hides_text_that_does_not_fit() { #[test] fn square_wrap_flows_text_on_both_sides() { + crate::test_fonts(); let float = |x: f32| wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, h_rel: wordcraft_doc::para::Anchor::Column, @@ -5298,6 +5417,7 @@ fn square_wrap_flows_text_on_both_sides() { /// still use it, and a word wider than the whole line still breaks where it is. #[test] fn a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; // A `w` x 150pt square at `x` in the 468pt column, leaving a 32pt strip on its right. Each // line: (top, left, beside the object's far side, ends inside a word, where its text ends). @@ -5380,6 +5500,7 @@ fn page_float(x: f32, y: f32) -> wordcraft_doc::para::Float { #[test] fn text_boxes_show_in_headers_footers_cells_and_notes() { + crate::test_fonts(); let mut d = Document::from_text(&"Body paragraph.\n".repeat(4)); // Header: an inline box and a box placed on the page. let hid = @@ -5423,6 +5544,7 @@ fn text_boxes_show_in_headers_footers_cells_and_notes() { #[test] fn footnotes_inside_text_boxes_are_numbered_and_placed() { + crate::test_fonts(); let mut d = Document::from_text(&"Body text line.\n".repeat(30)); let id = text_box_at(&mut d, &Pos::body(2, 0), "Box text", 144.0, 40.0, Default::default()); let e = d.end_of(StoryRef::Part(id)); @@ -5476,6 +5598,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document { #[test] fn self_showing_and_fanned_out_text_boxes_stay_bounded() { + crate::test_fonts(); // A box whose 30 shapes all show itself: laid out once. assert_eq!(lay(&box_fan_out(1, 30)).text_boxes, 1); // Chains of boxes each showing the next 30 times (30^4 expansions unbounded). @@ -5490,6 +5613,7 @@ fn self_showing_and_fanned_out_text_boxes_stay_bounded() { #[test] fn many_text_boxes_and_a_header_box_all_show() { + crate::test_fonts(); // Outermost boxes aren't budgeted: a long document's boxes, and its header's on every page, // all get their text. let mut d = Document::from_text("Paragraph with a box."); @@ -5516,6 +5640,7 @@ fn many_text_boxes_and_a_header_box_all_show() { #[test] fn a_text_box_inside_a_text_box_still_shows_its_text() { + crate::test_fonts(); let mut d = Document::from_text("Body"); let outer = text_box(&mut d, 0, "Outer ", 300.0, 200.0, Default::default()); let e = d.end_of(StoryRef::Part(outer)); @@ -5543,6 +5668,7 @@ fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject { #[test] fn line_spacing_does_not_scale_pictures() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; let mut d = Document::from_text(""); if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { @@ -5562,6 +5688,7 @@ fn line_spacing_does_not_scale_pictures() { #[test] fn multiple_line_spacing_adds_its_leading_below_the_text() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; // (spacing, expected baseline below the line top as a share of the ascent) for (spacing, share) in [(LineSpacing::Multiple(1.15), 1.0), (LineSpacing::Multiple(2.0), 1.0), (LineSpacing::Multiple(0.8), 0.8)] { @@ -5596,6 +5723,7 @@ fn multiple_line_spacing_adds_its_leading_below_the_text() { #[test] fn list_label_does_not_inflate_an_inline_picture_floor() { + crate::test_fonts(); use wordcraft_doc::props::{LineSpacing, NumRef}; for spacing in [LineSpacing::Multiple(1.25), LineSpacing::AtLeast(12.0)] { let mut d = Document::from_text(""); @@ -5670,6 +5798,7 @@ fn padding_without_a_border_takes_room_in_odt_documents() { #[test] fn body_starts_right_below_a_tall_header() { + crate::test_fonts(); let mut d = Document::from_text("Body"); let mut hp = wordcraft_doc::Paragraph::with_text("", Default::default()); hp.insert_object(0, picture(100.0, 80.0, Default::default()), &Default::default()).unwrap(); @@ -5694,6 +5823,7 @@ fn body_starts_right_below_a_tall_header() { #[test] fn inline_picture_keeps_room_for_its_effects() { + crate::test_fonts(); let mut d = Document::from_text(""); let shadow = wordcraft_doc::para::Float { effect: [6.0, 12.0, 18.0, 27.0], ..Default::default() }; if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { @@ -5800,6 +5930,7 @@ fn line_tops(l: &DocLayout) -> Vec { #[test] fn at_least_rows_add_cell_margins_and_border_bands() { + crate::test_fonts(); use wordcraft_doc::props::{Border, BorderStyle, Borders, HeightRule}; let mut d = Document::from_text(""); let line = Some(Border { style: BorderStyle::Single, width: 0.5, color: None, space: 0.0 }); @@ -5823,6 +5954,7 @@ fn at_least_rows_add_cell_margins_and_border_bands() { /// own lines up with a neighbour that has them, and the row is as tall as if all had them. #[test] fn a_row_shares_its_largest_top_and_bottom_margins() { + crate::test_fonts(); let mut d = Document::from_text(""); let mut t = Table::new(3, 2, 300.0); t.props.cell_margins = Some([0.0, 5.4, 0.0, 5.4]); @@ -5848,6 +5980,7 @@ fn a_row_shares_its_largest_top_and_bottom_margins() { /// and bottom margins steps 39pt; the text starts 4pt below its top). #[test] fn exact_rows_add_only_the_bottom_margin() { + crate::test_fonts(); use wordcraft_doc::props::HeightRule; let mut d = Document::from_text(""); let mut t = Table::new(2, 1, 200.0); @@ -5867,6 +6000,7 @@ fn exact_rows_add_only_the_bottom_margin() { /// 90 pt. #[test] fn exact_row_moves_whole() { + crate::test_fonts(); use wordcraft_doc::props::HeightRule; let mut t = Table::new(1, 1, 400.0); t.rows[0].props.height = Some(63.6); @@ -5903,6 +6037,7 @@ fn exact_row_moves_whole() { /// each cell's text starts its left margin inside the cell. #[test] fn cell_margins_are_where_cell_text_starts() { + crate::test_fonts(); let mut d = Document::from_text(""); let mut t = Table::new(1, 2, 300.0); t.props.cell_margins = Some([0.0, 9.0, 0.0, 3.0]); @@ -5954,6 +6089,7 @@ fn justified_lines(text: &str, compat_mode: u32, align: Align) -> (usize, Vec f32 { #[test] fn floats_align_within_their_reference_area() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, FloatAlign, Wrap}; let mut d = Document::from_text("Some text beside the pictures."); let centred = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Margin, h_align: Some(FloatAlign::Center), x: 999.0, ..Default::default() }; @@ -6191,6 +6330,7 @@ fn floats_align_within_their_reference_area() { #[test] fn top_and_bottom_float_pushes_its_own_first_line_down() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; let mut d = Document::from_text(&"Text that starts below the picture. ".repeat(10)); let f = Float { wrap: Wrap::TopAndBottom, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist_bottom: 6.0, ..Default::default() }; @@ -6206,6 +6346,7 @@ fn top_and_bottom_float_pushes_its_own_first_line_down() { #[test] fn float_follows_its_paragraph_to_the_next_page() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; let mut d = Document::from_text(&format!("First page.\n{}", "Text beside the picture on page two. ".repeat(20))); let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() }; @@ -6226,6 +6367,7 @@ fn float_follows_its_paragraph_to_the_next_page() { /// with the title: with nothing after its anchor, the box stays on the cover. #[test] fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() { + crate::test_fonts(); let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK)); d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap(); let l = lay(&d); @@ -6438,7 +6580,7 @@ fn a_footnote_pushed_on_by_a_float_coming_back_is_on_its_reference_s_page_only() vec![wordcraft_doc::para_block(p)] }; let noteref = |id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() }; - let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(30))); + let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(25))); d.insert_object(&Pos::body(0, 4), noteref(long), &Default::default()).unwrap(); let anchor = lines_on(&lay(&d), 1)[0].2; let short = d.add_part(wordcraft_doc::PartKind::Footnote, note("A short note.")); @@ -6506,7 +6648,7 @@ fn a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page() { #[test] fn a_float_anchored_on_a_line_widow_control_takes_on_is_drawn_on_the_next_page() { // A paragraph of three pages whose last two lines are on the third only by widow control. - let (mut d, l) = (150..300) + let (mut d, l) = (100..300) .map(|k| Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(k))) .map(|d| { let l = lay(&d); @@ -6619,6 +6761,7 @@ fn floats_are_drawn_in_their_stacking_order() { #[test] fn floats_in_headers_and_table_cells_are_drawn() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; let mut d = Document::from_text("Body text."); let logo = Float { wrap: Wrap::BehindText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 400.0, y: 20.0, ..Default::default() }; @@ -6824,6 +6967,7 @@ fn box_float_origins_respect_vertical_anchor_with_space_before() { /// its own keeps it (Word, observed with synthetic documents). #[test] fn automatic_text_in_a_styled_text_box_takes_the_box_s_colour() { + crate::test_fonts(); let glyph_colors = |auto_color: Option| { let mut d = Document::from_text("Body"); let id = text_box(&mut d, 4, "Auto", 200.0, 60.0, page_float(100.0, 300.0)); @@ -6854,6 +6998,7 @@ fn automatic_text_in_a_styled_text_box_takes_the_box_s_colour() { /// stays there. #[test] fn header_cell_float_placed_on_the_page() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, FloatAlign, Wrap}; let mut d = Document::from_text(&"Body text of the contract. ".repeat(400)); let wm = |outside_cell: bool| Float { @@ -6922,6 +7067,7 @@ fn a_centred_float_in_a_cell_is_not_pushed_above_its_anchor_line() { #[test] fn wrap_area_uses_each_distance() { + crate::test_fonts(); use wordcraft_doc::para::{Float, Wrap}; let f = Float { wrap: Wrap::Square, dist: 9.0, dist_top: 0.0, dist_bottom: 4.0, ..Default::default() }; let (r, tb, ..) = wrap_area(Rect::new(10.0, 20.0, 100.0, 50.0), &f, None).unwrap(); @@ -6934,6 +7080,7 @@ fn wrap_area_uses_each_distance() { #[test] fn float_moved_to_a_new_page_sits_at_its_paragraph_top() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; let mut d = Document::from_text(&format!("First page.\n{}", "Text beside the picture on page two. ".repeat(20))); let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() }; @@ -6953,6 +7100,7 @@ fn float_moved_to_a_new_page_sits_at_its_paragraph_top() { #[test] fn page_relative_header_float_wraps_where_it_is_drawn() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; let body_top = |wrap| { let mut d = Document::from_text("Body text."); @@ -6974,6 +7122,7 @@ fn page_relative_header_float_wraps_where_it_is_drawn() { /// at the top margin and paginates exactly as without the box. #[test] fn floating_header_text_box_leaves_the_body_at_the_top_margin() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; let body: Vec = (1..=40).map(|i| format!("Body paragraph {i}: synthetic public test content.")).collect(); let make = |float: Option| { @@ -7045,6 +7194,7 @@ fn numbered(d: &mut Document, texts: &[&str], props: ParaProps) -> Vec = (0..24).map(|i| format!("Paragraph {i} before the table.")).collect(); @@ -7307,6 +7463,7 @@ fn a_page_anchored_floating_table_after_a_full_page_moves_to_the_next() { /// on that page: no blank page between the two tables around it. #[test] fn a_page_break_at_the_bottom_of_a_full_page_leaves_no_blank_page() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; let props = ParaProps { line_spacing: Some(LineSpacing::Exactly(24.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() }; let para = |text: &str| { @@ -7328,6 +7485,7 @@ fn a_page_break_at_the_bottom_of_a_full_page_leaves_no_blank_page() { /// size and win over the indent in points. #[test] fn character_indents_use_the_paragraph_mark_size() { + crate::test_fonts(); let mut p = wordcraft_doc::Paragraph::with_text(&"字".repeat(80), Default::default()); p.props.indent_first = Some(5.0); p.props.indent_first_chars = Some(2.0); @@ -7343,6 +7501,7 @@ fn character_indents_use_the_paragraph_mark_size() { #[test] fn no_line_break_right_after_a_slash() { + crate::test_fonts(); // Word keeps "and/or" and web addresses whole on a line when they fit. let text = "word and/or https://example.org/keepers/log/winter ".repeat(40); let d = Document::from_text(&text); @@ -7366,6 +7525,7 @@ fn no_line_break_right_after_a_slash() { /// "and/or" whole), so the slash word had no break before it and split between two letters. #[test] fn a_word_after_a_slash_wraps_whole() { + crate::test_fonts(); let (head, slash, tail) = ("ЖСН енгізілмеген жағдайда толтырылады", " /", "заполняется в случае неприсвоения ИИН"); let text = format!("{head}{slash}{tail}"); let italic = wordcraft_doc::CharProps { italic: Some(true), ..Default::default() }; @@ -7410,6 +7570,7 @@ fn a_word_after_a_slash_wraps_whole() { /// #2963 happened in a fixed-width table cell: the cell's own width is the line width there. #[test] fn a_word_after_a_slash_wraps_whole_in_a_fixed_cell() { + crate::test_fonts(); let (head, slash, tail) = ("ЖСН енгізілмеген жағдайда толтырылады", " /", "заполняется в случае неприсвоения ИИН"); let text = format!("{head}{slash}{tail}"); let italic = wordcraft_doc::CharProps { italic: Some(true), ..Default::default() }; @@ -7457,6 +7618,7 @@ fn a_word_after_a_slash_wraps_whole_in_a_fixed_cell() { /// slash keeps it on the line. #[test] fn a_no_break_space_before_a_slash_still_holds() { + crate::test_fonts(); let starts = |nb: char| -> (usize, usize) { // Varying word lengths, so the lines end at different places in the pattern. let text: String = (0..80).map(|i| format!("caption{nb}/word {} ", "w".repeat(1 + i % 5))).collect(); @@ -7485,6 +7647,7 @@ fn a_no_break_space_before_a_slash_still_holds() { #[test] fn unequal_formats_lay_out_in_linear_time() { + crate::test_fonts(); // Runs whose formats never compare equal (NaN spacing from a hostile file) each get a style; // finding a run's style must not compare it against every earlier one. let doc = |n: usize| { @@ -7507,6 +7670,7 @@ fn unequal_formats_lay_out_in_linear_time() { #[test] fn formats_past_65536_in_a_paragraph_keep_their_own_style() { + crate::test_fonts(); // More distinct formats than a 16-bit index counts (the paragraph mark takes a style too): every // run past that limit still draws with its own link, deletion and size, not an earlier run's. let n = usize::from(u16::MAX) + 3; @@ -7539,6 +7703,7 @@ fn formats_past_65536_in_a_paragraph_keep_their_own_style() { #[test] fn hidden_float_in_a_table_cell_takes_no_room() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; // A square-wrapped shape anchored in hidden text inside a table cell, beside wrapping text. let mut d = Document::from_text("Body text."); @@ -7566,6 +7731,7 @@ fn hidden_float_in_a_table_cell_takes_no_room() { #[test] fn objects_left_out_through_the_table_style_formatting() { + crate::test_fonts(); // The table style's character formatting sits under the runs' own, as the cell is laid out. let mut p = wordcraft_doc::Paragraph::with_text("ab", Default::default()); p.insert_object(1, rect_shape(10.0, 10.0, Default::default()), &Default::default()).unwrap(); @@ -7578,6 +7744,7 @@ fn objects_left_out_through_the_table_style_formatting() { #[test] fn hidden_float_in_a_text_box_is_not_placed() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; // A shape anchored in hidden text inside a text box: no drawing, no hit area. let mut d = Document::from_text("Body text."); @@ -7599,6 +7766,7 @@ fn hidden_float_in_a_text_box_is_not_placed() { #[test] fn inline_graphic_reserves_its_height() { + crate::test_fonts(); let mut d = Document::from_text("Chart\nAfter"); let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) }; d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap(); @@ -7610,6 +7778,7 @@ fn inline_graphic_reserves_its_height() { #[test] fn graphic_items_draw_inside_the_object() { + crate::test_fonts(); use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign}; use wordcraft_doc::props::Rgb; let items = vec![ @@ -7653,6 +7822,7 @@ fn graphic_items_draw_inside_the_object() { #[test] fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() { + crate::test_fonts(); use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign}; use wordcraft_doc::props::Rgb; let text = |rect: [f32; 4], size: f32| GraphicItem::Text { @@ -7678,6 +7848,7 @@ fn chart_text_the_font_lacks_is_drawn_in_a_fallback_face() { // #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; + crate::test_fonts(); if wordcraft_fonts::FontDb::global().fallback_for('销', 0).is_none() { eprintln!("skipped: no Chinese font installed"); return; @@ -7724,6 +7895,7 @@ fn flat(draws: Vec) -> Vec { #[test] fn inline_chart_is_a_figure_with_its_alt_text() { + crate::test_fonts(); use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg}; use wordcraft_doc::props::Rgb; let items = vec![GraphicItem::Path { @@ -7743,6 +7915,7 @@ fn inline_chart_is_a_figure_with_its_alt_text() { #[test] fn graphic_paths_drop_segments_that_cannot_be_drawn() { + crate::test_fonts(); use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg}; use wordcraft_doc::props::Rgb; // The line before the first move, the move with a NaN, the infinite line and the line after the @@ -7769,6 +7942,7 @@ fn graphic_paths_drop_segments_that_cannot_be_drawn() { #[test] fn graphic_items_scale_with_their_frame() { + crate::test_fonts(); use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::props::Rgb; // A chart drawn for 1000 x 500 pt in a 500 x 250 pt frame: its items and strokes scale by half. @@ -7794,6 +7968,7 @@ fn graphic_items_scale_with_their_frame() { #[test] fn inline_picture_wider_than_a_text_box_is_fitted_to_it() { + crate::test_fonts(); // Text boxes don't clip what runs past their sides, so a 400 x 100 pt picture in a 144 pt box // is fitted to the box's 129.6 pt inside its margins instead of spilling over the page. let mut d = Document::from_text("Before"); @@ -7828,6 +8003,7 @@ fn inline_picture_wider_than_a_text_box_is_fitted_to_it() { #[test] fn inline_picture_wider_than_the_text_keeps_its_size() { + crate::test_fonts(); // A 1000 x 100 pt picture in 468 pt of text: Word draws it at its size, past the margin, and the // page edge clips it; the line is as tall as the picture (docxide-pdf's case179). let mut d = Document::from_text(""); @@ -7848,6 +8024,7 @@ fn inline_picture_wider_than_the_text_keeps_its_size() { /// the anchor an empty line of its own above it (Word). #[test] fn a_float_anchor_does_not_push_a_wide_picture_to_a_second_line() { + crate::test_fonts(); let lines_of = |with_anchor: bool| { let mut d = Document::from_text(""); let mut at = Pos::body(0, 0); @@ -7865,6 +8042,7 @@ fn a_float_anchor_does_not_push_a_wide_picture_to_a_second_line() { #[test] fn a_picture_too_wide_after_a_content_control_start_stays_on_the_first_line() { + crate::test_fonts(); // The picture opening a picture content control (a logo in a text box a little too // narrow for it): the control's start marker before it takes no room, so the picture is // still the line's first item and stays there instead of leaving an empty line above it. @@ -7895,6 +8073,7 @@ fn para_at(l: &DocLayout, path: &[u32]) -> Vec<(f32, f32, usize)> { #[test] fn a_floating_table_in_a_cell_takes_no_room_and_the_cell_holds_it() { + crate::test_fonts(); use wordcraft_doc::para::Anchor; use wordcraft_doc::props::TableFloat; let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default())); @@ -7990,6 +8169,7 @@ fn a_behind_or_in_front_of_text_picture_in_a_cell_leaves_the_row_alone() { #[test] fn autofit_sizes_columns_by_their_content() { + crate::test_fonts(); let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default())); // No width anywhere: the columns come from their content. let mut t = Table::new(1, 2, 468.0); @@ -8014,6 +8194,7 @@ fn autofit_sizes_columns_by_their_content() { #[test] fn autofit_handles_spanning_cells_long_words_and_empty_tables() { + crate::test_fonts(); let text = |s: &str| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(s, Default::default())); let table_x = |t: Table, path: &[u32]| { let mut d = Document::from_text("After"); @@ -8055,6 +8236,7 @@ fn autofit_handles_spanning_cells_long_words_and_empty_tables() { #[test] fn an_inherited_footer_sizes_its_percentage_table_by_its_own_section() { + crate::test_fonts(); use wordcraft_doc::section::SectionProps; // Measured in Word: a 100%-wide footer table defined by a portrait section keeps that width on // a landscape page that inherits the footer, centred there; a landscape section naming the @@ -8089,6 +8271,7 @@ fn an_inherited_footer_sizes_its_percentage_table_by_its_own_section() { /// remain. #[test] fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { + crate::test_fonts(); use wordcraft_doc::props::HeightRule; // Exactly 50pt of measured room remains. The same eight paragraphs split without a minimum, // but an at-least height of 63.6pt moves the row to the next page whole. @@ -8236,6 +8419,7 @@ fn lines_in_cell(n: usize, what: &str) -> Vec Vec { #[test] fn long_footnotes_continue_on_the_next_page() { + crate::test_fonts(); let mut d = Document::from_text(&"Body text line.\n".repeat(16)); let long = footnote(&mut d, &Pos::body(4, 4), &"A long footnote that runs on and on. ".repeat(100)); let own = footnote(&mut d, &Pos::body(14, 4), "The second page's own note."); @@ -8787,6 +8982,7 @@ fn long_footnotes_continue_on_the_next_page() { #[test] fn short_footnotes_stay_whole() { + crate::test_fonts(); let mut d = Document::from_text(&"Body text line.\n".repeat(60)); let id = footnote(&mut d, &Pos::body(10, 4), "A short note, two lines long. ".repeat(4).as_str()); let l = lay(&d); @@ -8797,6 +8993,7 @@ fn short_footnotes_stay_whole() { #[test] fn footnote_longer_than_pages_ends() { + crate::test_fonts(); // A note several pages long after a one-line body: it continues on pages of its own. let mut d = Document::from_text("Body"); let id = footnote(&mut d, &Pos::body(0, 4), &"Words in a very long note. ".repeat(1500)); @@ -8839,6 +9036,7 @@ fn comment_lines(l: &DocLayout, pi: usize) -> Vec<(StoryRef, f32, f32)> { /// reply are laid out there beside the line they comment on. #[test] fn comment_balloons_widen_pages_beside_their_anchors() { + crate::test_fonts(); let mut d = Document::from_text("Intro line.\nThe quarterly figures look strong.\nClosing line."); let id = comment_on(&mut d, 1, 4, 13, "Ada Reviewer", "Check the totals", None); comment_on(&mut d, 0, 0, 0, "Ben Editor", "Totals checked", Some(id)); @@ -8865,6 +9063,7 @@ fn comment_balloons_widen_pages_beside_their_anchors() { #[test] fn no_balloons_without_anchored_comments() { + crate::test_fonts(); let d = Document::from_text("No comments here."); let l = balloon_layout(&d); assert_eq!(l.pages[0].markup, 0.0); @@ -8881,6 +9080,7 @@ fn no_balloons_without_anchored_comments() { /// one page. Nothing panics or hangs, and the cards stay on the page. #[test] fn comment_balloons_survive_hostile_comments() { + crate::test_fonts(); let mut d = Document::from_text(&"Commented words here. ".repeat(20)); let mut ids = Vec::new(); for i in 0..120 { @@ -8934,6 +9134,7 @@ fn wrapped_shape_doc(kind: wordcraft_doc::para::ShapeKind, w: f32, h: f32, float #[test] fn tight_wrap_follows_an_ellipse() { + crate::test_fonts(); use wordcraft_doc::para::{Float, ShapeKind, Wrap}; let spans = |wrap| { let mut d = wrapped_shape_doc(ShapeKind::Ellipse, 160.0, 160.0, Float { wrap, ..Default::default() }); @@ -8975,6 +9176,7 @@ fn tight_wrap_follows_an_ellipse() { #[test] fn contour_wrap_turns_with_the_object() { + crate::test_fonts(); use wordcraft_doc::para::{Float, ShapeKind, Wrap}; use wordcraft_doc::wrap::WrapPolygon; // A wrap polygon covering the left half of a 200 × 100 frame. @@ -9002,6 +9204,7 @@ fn contour_wrap_turns_with_the_object() { /// symbol" a bigger shape around text at its own size. Plain `\o` overstrikes its arguments. #[test] fn enclosed_characters_draw_a_shape_around_the_text() { + crate::test_fonts(); use wordcraft_doc::eq::{EncloseShape, EncloseStyle, Enclosure}; let draw = |shape: EncloseShape, style: EncloseStyle| { let mut d = Document::from_text("a b"); @@ -9120,6 +9323,7 @@ fn superscript_digits_advance_at_the_half_point_size() { #[test] fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() { + crate::test_fonts(); use wordcraft_doc::para::RubyAlign; let plain = Document::from_text("Plain words here"); let mut d = Document::from_text("Plain here"); @@ -9165,6 +9369,7 @@ fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() { /// only to its right; Largest: only on its wider side. #[test] fn wrap_text_keeps_text_on_one_side() { + crate::test_fonts(); use wordcraft_doc::para::{Float, ShapeKind, Wrap, WrapText}; // A 100pt shape at `x` in the 468pt column; the lines beside it. let beside = |kind, wrap, x: f32, side| { @@ -9191,6 +9396,7 @@ fn wrap_text_keeps_text_on_one_side() { #[test] fn document_note_separators_and_continuation_notice() { + crate::test_fonts(); let mut d = Document::from_text(&"Body text line.\n".repeat(16)); let long = footnote(&mut d, &Pos::body(4, 4), &"A long footnote that runs on and on. ".repeat(100)); let sep = |cont: bool| { @@ -9246,6 +9452,7 @@ fn document_note_separators_and_continuation_notice() { /// frame, so the clip turns too); floating or inline. Hit testing follows the turned canvas. #[test] fn rotated_canvas_draws_its_clipped_members_inside_the_turn() { + crate::test_fonts(); use crate::display::Draw; use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap}; let member = InlineObject::Shape { @@ -9335,6 +9542,7 @@ fn rotated_canvas_draws_its_clipped_members_inside_the_turn() { #[test] fn mirror_indents_swap_sides_on_even_pages() { + crate::test_fonts(); // #512: with mirrored indents the left indent is the inside one, on the right of an even page. let mut d = Document::from_text("odd page\neven page"); let last = Pos::body(1, 0); @@ -9509,6 +9717,7 @@ fn column_bottoms(l: &DocLayout) -> (usize, f32, usize, f32, (usize, f32)) { #[test] fn columns_before_a_continuous_break_are_balanced() { + crate::test_fonts(); use wordcraft_doc::section::SectionStart; for widths in [vec![], vec![(150.0, 36.0), (282.0, 0.0)]] { let l = lay(&two_column_section(SectionStart::Continuous, widths.clone())); @@ -9524,6 +9733,7 @@ fn columns_before_a_continuous_break_are_balanced() { #[test] fn columns_before_a_page_break_or_a_column_break_are_not_balanced() { + crate::test_fonts(); use wordcraft_doc::section::SectionStart; // Next-page break: the ten paragraphs fill the first column; the next section is on page 2. let l = lay(&two_column_section(SectionStart::NextPage, vec![])); @@ -9583,6 +9793,7 @@ fn note_marks(l: &DocLayout) -> HashMap> { #[test] fn footnotes_restart_on_each_page_and_section() { + crate::test_fonts(); use wordcraft_doc::section::{NoteProps, NoteRestart}; let mut d = Document::from_text(&"Body text line.\n".repeat(120)); let a = footnote(&mut d, &Pos::body(2, 4), "First note."); @@ -9608,6 +9819,7 @@ fn footnotes_restart_on_each_page_and_section() { #[test] fn endnotes_at_section_end_and_footnotes_below_text() { + crate::test_fonts(); use wordcraft_doc::section::{NotePos, NoteProps}; let mut d = Document::from_text("Alpha\nBeta\nGamma\nDelta"); let eid = d.add_part( @@ -9643,6 +9855,7 @@ fn endnotes_at_section_end_and_footnotes_below_text() { /// styles): Polish patterns and dictionary for Polish runs, English ones for English runs. #[test] fn hyphenation_and_proofing_follow_each_runs_language() { + crate::test_fonts(); // A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled). wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap(); let text = "Konstytucyjnego żeka typography beautifull"; @@ -9678,6 +9891,7 @@ fn hyphenation_and_proofing_follow_each_runs_language() { /// style's; a paragraph that switches the style's list off (`num: 0`) is plain again. #[test] fn list_item_first_word_gets_no_capital_squiggle() { + crate::test_fonts(); use wordcraft_doc::props::NumRef; let opts = LayoutOptions { proofing: true, ..LayoutOptions::default() }; let grammar_squiggles = |d: &Document| -> usize { @@ -9713,6 +9927,7 @@ fn list_item_first_word_gets_no_capital_squiggle() { /// or grammar" text none either; the document default language applies to text without its own. #[test] fn unproofed_languages_and_no_proof_runs_are_not_marked() { + crate::test_fonts(); // A tiny synthetic Polish dictionary (real ones are installed on the system, not bundled). wordcraft_proof::pl::install_dictionary("SET UTF-8\nSFX B Y 1\nSFX B y ego y\n", "2\nkonstytucyjny/B\npłynie\n").unwrap(); let opts = LayoutOptions { proofing: true, ..LayoutOptions::default() }; @@ -9737,6 +9952,7 @@ fn unproofed_languages_and_no_proof_runs_are_not_marked() { /// ignored), on every page, like the same fields in the footer. #[test] fn shared_section_page_totals() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; use wordcraft_doc::section::{SectionProps, SectionStart}; #[derive(Clone, Copy)] @@ -9804,6 +10020,7 @@ fn shared_section_page_totals() { #[test] fn body_page_totals_count_every_page() { + crate::test_fonts(); let mut d = Document::from_text("Body-1\nBody-2\nBody-3"); let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() }; @@ -9842,6 +10059,7 @@ fn body_page_totals_count_every_page() { /// a body paragraph. #[test] fn table_cell_footnotes_are_placed() { + crate::test_fonts(); let mut d = Document::from_text("After the table."); d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(wordcraft_doc::Table::new(1, 1, 300.0))).unwrap(); let cell = Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, 0]), off: 0 }; @@ -9857,6 +10075,7 @@ fn table_cell_footnotes_are_placed() { #[test] fn east_asian_text_takes_the_asian_font_and_latin_text_the_latin_font() { + crate::test_fonts(); // #505: one run with a Latin font and an Asian font (w:eastAsia) of its own sets each script in // its font. Prefer an installed CJK font other than the one fallback would pick anyway. let resolve = |f: &str| wordcraft_fonts::word::resolve(f, false, false); @@ -9898,6 +10117,7 @@ fn east_asian_text_takes_the_asian_font_and_latin_text_the_latin_font() { /// the body lay out a second time; one in the body still does. #[test] fn footer_page_total_needs_one_body_pass() { + crate::test_fonts(); let field = || InlineObject::Field { instr: "NUMPAGES".into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() }; let mut d = Document::from_text("Body-1\nBody-2\nBody-3"); @@ -9924,6 +10144,7 @@ fn footer_page_total_needs_one_body_pass() { /// Cyrillic text in an English run is not hyphenated (the English data has nothing to say about it). #[test] fn bulgarian_text_is_hyphenated_by_its_own_rules() { + crate::test_fonts(); let text = "Сричкопренасянето в професионалните документи задължително следва правописните правила, разработени от специалистите. ".repeat(6); let hyphenated = |d: &Document| -> Vec<(String, String)> { let mut out = Vec::new(); @@ -9961,6 +10182,7 @@ fn bulgarian_text_is_hyphenated_by_its_own_rules() { /// grow it). Tables of contents with big tab runs were a third taller than Word's. #[test] fn spaces_and_tabs_do_not_make_a_line_taller() { + crate::test_fonts(); use wordcraft_doc::props::CharProps; let small = CharProps { size: Some(9.0), ..Default::default() }; let big = CharProps { size: Some(20.0), ..Default::default() }; @@ -9996,6 +10218,7 @@ fn spaces_and_tabs_do_not_make_a_line_taller() { /// the page bottom. #[test] fn a_list_label_adds_ascent_but_not_descent() { + crate::test_fonts(); let first_line = |label_size: Option| { let mut d = Document::new(); let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered); @@ -10026,6 +10249,7 @@ fn a_list_label_adds_ascent_but_not_descent() { /// with text is never hidden. NIH's Table A showed a blank row Word doesn't. #[test] fn a_hidden_cell_mark_takes_no_room() { + crate::test_fonts(); let row3_top = |hide: [bool; 2], second: Option<&str>| { let mut d = Document::from_text("Above"); let mut t = Table::new(3, 2, 300.0); @@ -10080,6 +10304,7 @@ fn a_hidden_cell_mark_takes_no_room() { /// an ordinary paragraph, a line of its own above the footer text. #[test] fn a_text_frame_in_a_cell_floats_beside_the_text() { + crate::test_fonts(); use wordcraft_doc::props::TextFrame; let mut d = Document::from_text("Above"); let mut t = Table::new(1, 1, 300.0); @@ -10107,6 +10332,7 @@ fn a_text_frame_in_a_cell_floats_beside_the_text() { /// were drawn over each other (#2357). #[test] fn a_frame_inside_a_text_box_flows_with_its_text() { + crate::test_fonts(); use wordcraft_doc::props::TextFrame; let mut d = Document::from_text("Body"); let id = text_box(&mut d, 0, "", 150.0, 120.0, Default::default()); @@ -10132,6 +10358,7 @@ fn a_frame_inside_a_text_box_flows_with_its_text() { /// the list's 0.6", and its "sample text" paragraphs with the list off at 0.9" instead of the margin. #[test] fn list_indents_and_style_indents_combine_like_word() { + crate::test_fonts(); use wordcraft_doc::props::NumRef; use wordcraft_doc::styles::{Style, StyleKind}; let text_x = |style: &str, own: ParaProps| { @@ -10183,6 +10410,7 @@ fn list_indents_and_style_indents_combine_like_word() { /// five-line kept paragraph left a third of a page empty. #[test] fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() { + crate::test_fonts(); // Page of each of the kept paragraph's 5 lines and of the first paragraph after it. let pages = |fillers: usize, kept: ParaProps, next: ParaProps| { let mut d = Document::new(); @@ -10229,6 +10457,7 @@ fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() { /// and the heading keeps it company. #[test] fn keep_with_next_carries_the_heading_when_widow_control_moves_the_next_paragraph() { + crate::test_fonts(); // Page of the heading, then of the next paragraph's lines at "Bb", "Bc" and "Be". let pages = |fillers: usize, widow: bool| -> [usize; 4] { let mut d = Document::new(); @@ -10313,6 +10542,7 @@ fn keep_with_next_carries_the_heading_when_a_three_line_paragraph_moves_whole() /// over when the last one cannot start on the page, and all stay when it can. #[test] fn keep_with_next_chain_carries_over_when_the_last_paragraph_cannot_start() { + crate::test_fonts(); let pages = |fillers: usize, widow: bool| -> Vec { let mut d = Document::new(); let plain = ParaProps { @@ -10355,6 +10585,7 @@ fn keep_with_next_chain_carries_over_when_the_last_paragraph_cannot_start() { /// of a paragraph that keep lines together will not split (#966). #[test] fn keep_with_next_carries_the_heading_when_keep_lines_moves_the_next_paragraph() { + crate::test_fonts(); let pages = |fillers: usize, kept_lines: bool| -> Vec { let mut d = Document::new(); let plain = ParaProps { @@ -10393,6 +10624,7 @@ fn keep_with_next_carries_the_heading_when_keep_lines_moves_the_next_paragraph() #[test] fn section_mark_hidden_styles() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; use wordcraft_doc::section::{SectionProps, SectionStart}; use wordcraft_doc::{CharProps, Style, StyleKind}; @@ -10438,6 +10670,7 @@ fn section_mark_hidden_styles() { #[test] fn empty_page_section_keeps_line() { + crate::test_fonts(); use wordcraft_doc::section::{SectionProps, SectionStart}; let mut doc = Document::from_text("\nAfter empty section\n\nReference prefix\n\nAfter middle mark"); for index in 0..6 { @@ -10481,6 +10714,7 @@ fn a_page_break_before_after_a_continuous_mark_starts_a_page() { #[test] fn an_empty_continuous_section_mark_takes_no_room() { + crate::test_fonts(); use wordcraft_doc::section::{SectionProps, SectionStart}; // "Pa", the section's last paragraph (`mark`), "Pb", "Pc" in the next section of `cols` // columns, then "Pd" after a second break. @@ -10625,6 +10859,7 @@ fn a_run_of_empty_continuous_section_marks_keeps_a_line_each() { /// form's field boxes sat a space-before too low, over their labels. #[test] fn paragraph_relative_floats_start_above_the_space_before() { + crate::test_fonts(); let shape_top = |before: f32, prev_after: f32, y: f32| { let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::InFrontOfText, @@ -10683,6 +10918,7 @@ fn paragraph_relative_floats_start_above_the_space_before() { /// column it started in, so a paragraph took a dozen lines a word or two wide. #[test] fn text_after_a_column_break_takes_the_new_column_s_width() { + crate::test_fonts(); let mut d = Document::new(); d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: vec![(67.0, 52.0), (348.0, 0.0)] }; let brk = wordcraft_doc::para::COLUMN_BREAK; @@ -10703,6 +10939,7 @@ fn text_after_a_column_break_takes_the_new_column_s_width() { /// modes up to 14 (less the previous paragraph's space after) and drops it in mode 15. #[test] fn space_before_at_the_top_of_a_page_like_word() { + crate::test_fonts(); let para = |text: &str, before: f32, pbb: bool| { let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); p.props = ParaProps { space_before: Some(before), space_after: Some(0.0), page_break_before: Some(pbb), ..Default::default() }; @@ -10785,6 +11022,7 @@ fn space_before_at_the_top_of_a_page_like_word() { /// all unkerned). #[test] fn kerning_follows_kerning_for_fonts_like_word() { + crate::test_fonts(); use wordcraft_doc::props::CharProps; let width = |kern: Option| { let mut d = Document::new(); @@ -10810,6 +11048,7 @@ fn kerning_follows_kerning_for_fonts_like_word() { /// turned them off. Without a value WordCraft keeps its default, standard and contextual. #[test] fn ligatures_follow_w14_ligatures() { + crate::test_fonts(); use wordcraft_doc::props::{CharProps, Ligatures}; use wordcraft_doc::styles::{Style, StyleKind}; let text = "office affinity"; @@ -10848,6 +11087,7 @@ fn ligatures_follow_w14_ligatures() { /// historical `hlig`, discretional `dlig`. #[test] fn ligature_flags_map_to_opentype_features() { + crate::test_fonts(); use wordcraft_doc::props::Ligatures; let f = |t: &[u8; 4], v: u32| wordcraft_fonts::feature_value(t, v); let hd = Ligatures { historical: true, discretional: true, ..Default::default() }; @@ -10861,6 +11101,7 @@ fn ligature_flags_map_to_opentype_features() { /// cell; next to another paragraph's space the larger of the two counts. #[test] fn automatic_paragraph_spacing_like_word() { + crate::test_fonts(); let auto = |before: bool, after: bool| ParaProps { space_before: Some(5.0), space_after: Some(5.0), @@ -10908,6 +11149,7 @@ fn automatic_paragraph_spacing_like_word() { /// In a table cell automatic spacing drops at the cell's top and bottom (Word for Windows). #[test] fn automatic_paragraph_spacing_drops_at_cell_edges() { + crate::test_fonts(); let cell_tops = |auto: bool| { let mut d = Document::from_text("Above"); let mut t = Table::new(1, 1, 300.0); @@ -10946,6 +11188,7 @@ fn automatic_paragraph_spacing_drops_at_cell_edges() { /// (each item automatic before and after) were 14 pt apart instead of touching. #[test] fn automatic_spacing_drops_between_list_items() { + crate::test_fonts(); use wordcraft_doc::props::NumRef; let auto = ParaProps { space_before: Some(5.0), @@ -11022,6 +11265,7 @@ fn automatic_spacing_drops_between_list_items() { /// `hyphenation_points` keeps it so whatever the patterns become.) #[test] fn automatic_hyphenation_leaves_vietnamese_whole() { + crate::test_fonts(); let text = "Những người khuyến khích chuyện trường thường nghiêng nghiêng nhưng thành phố Nguyễn Trường Thượng xuyên suốt chương trình. ".repeat(8); let mut d = Document::from_text(&text); @@ -11038,6 +11282,7 @@ fn automatic_hyphenation_leaves_vietnamese_whole() { /// them), and so is the text drawn on them; a paragraph's shading is an ordinary fill. #[test] fn highlight_fill_is_marked_as_a_text_background() { + crate::test_fonts(); use wordcraft_doc::props::Highlight; let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Yellow), ..Default::default() }; let mut p = wordcraft_doc::Paragraph::with_text("Marked", cp); @@ -11064,6 +11309,7 @@ fn highlight_fill_is_marked_as_a_text_background() { /// its glyphs; other lines (here a paragraph border) don't. #[test] fn underline_on_a_highlight_carries_the_text_background() { + crate::test_fonts(); use wordcraft_doc::props::{Highlight, Underline}; let cp = wordcraft_doc::CharProps { highlight: Some(Highlight::Teal), underline: Some(Underline::Single), strike: Some(true), ..Default::default() }; @@ -11138,6 +11384,7 @@ fn assert_contiguous(ranges: &[std::ops::Range], len: usize) { /// #1352: a PAGE field after a page break inside its paragraph shows the page it is on. #[test] fn page_field_after_intra_paragraph_break_shows_its_page() { + crate::test_fonts(); let brk = wordcraft_doc::para::PAGE_BREAK; let d = page_field_doc(0, &["A", &format!("{brk}B")]); let l = lay(&d); @@ -11149,6 +11396,7 @@ fn page_field_after_intra_paragraph_break_shows_its_page() { /// #1352: a paragraph running over onto the next page shows that page's number in its tail. #[test] fn page_field_in_a_split_paragraph_tail_shows_its_page() { + crate::test_fonts(); let long = "words that fill the page ".repeat(400); let d = page_field_doc(0, &["Head", &long]); let l = lay(&d); @@ -11164,6 +11412,7 @@ fn page_field_in_a_split_paragraph_tail_shows_its_page() { /// again for the wider results, and the paragraph's text is still placed exactly once. #[test] fn page_fields_from_page_nine_to_ten_rebreak_the_rest() { + crate::test_fonts(); let pieces: Vec = (0..900).map(|i| format!(" word{i}")).collect(); let pieces: Vec<&str> = pieces.iter().map(String::as_str).collect(); let d = page_field_doc(8, &pieces); @@ -11196,6 +11445,7 @@ fn page_fields_from_page_nine_to_ten_rebreak_the_rest() { /// shows the page the footnote is on (the paragraph's own page map doesn't leak into it). #[test] fn footnote_page_field_from_a_split_paragraph_shows_its_page() { + crate::test_fonts(); let long = "words that fill the page ".repeat(200); let mut d = page_field_doc(0, &["Head", &long, "Tail"]); let end = d.end_of(StoryRef::Body); @@ -11292,6 +11542,7 @@ fn an_equations_box_spans_its_width_and_its_line() { /// for a later page's PAGE fields) doesn't count its numbered equations twice: they read (1), (2), (3). #[test] fn relaid_paragraphs_keep_their_equation_numbers() { + crate::test_fonts(); let eq = |s: &str| InlineObject::Equation { linear: s.into(), display: true, @@ -11362,6 +11613,7 @@ fn para_pieces(l: &DocLayout, t: usize) -> (Vec<(usize, usize, usize, f32)>, usi #[test] fn space_after_stays_clear_of_the_footnote_separator() { + crate::test_fonts(); // #1116, measured in Word: on a page with footnotes a paragraph ending there keeps its space // after above the separator, or its last lines (two, with widow control) go to the next page // with the note. @@ -11390,6 +11642,7 @@ fn space_after_stays_clear_of_the_footnote_separator() { #[test] fn space_after_may_overflow_the_bottom_margin_without_notes() { + crate::test_fonts(); // Without footnotes nothing changes: the last line only has to fit, its space after may run // past the bottom margin (Word). "Fit" means its text: the leading multiple spacing adds below // the text (this paragraph's 1.15) may run past the margin too (observed in Word at 1.15, 1.5 @@ -11446,6 +11699,7 @@ fn para_props(d: &mut Document, i: usize) -> &mut ParaProps { #[test] fn space_after_stays_clear_when_only_the_next_paragraph_has_contextual_spacing() { + crate::test_fonts(); // Contextual spacing on the next paragraph alone drops its space before, not this one's space // after (as the spacing between them): the space after still stays clear of the separator. space_after_clear_with(|d, t| para_props(d, t + 1).contextual_spacing = Some(true), 6.0); @@ -11453,6 +11707,7 @@ fn space_after_stays_clear_when_only_the_next_paragraph_has_contextual_spacing() #[test] fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() { + crate::test_fonts(); // Automatic spacing drops only between two list items with labels: before a paragraph whose // list doesn't exist (no label) a list item's automatic space after stays clear. use wordcraft_doc::props::NumRef; @@ -11513,6 +11768,7 @@ fn shape_fills_paint_their_frame() { #[test] fn cell_widths_override_grid() { + crate::test_fonts(); for fixed in [false, true] { let mut t = Table::new(2, 3, 450.0); t.props.fixed = fixed; @@ -11536,6 +11792,7 @@ fn cell_widths_override_grid() { #[test] fn ambiguous_widths_keep_grid() { + crate::test_fonts(); let mut t = Table::new(2, 3, 450.0); t.grid = vec![40.0, 175.0, 235.0]; for row in &mut t.rows { @@ -11630,6 +11887,7 @@ fn assert_clear_of(lines: &[(f32, f32, f32, f32)], r: Rect, what: &str) { /// #1374: a paragraph that runs on from column 1 into column 2 wraps around a shape in column 2. #[test] fn paragraph_continued_into_next_column_wraps_around_a_shape_there() { + crate::test_fonts(); let r = Rect::new(340.0, 300.0, 100.0, 100.0); let d = column_wrap_doc(2, Some(page_shape(r.x, r.y, r.w, r.h)), 450); let l = lay(&d); @@ -11644,6 +11902,7 @@ fn paragraph_continued_into_next_column_wraps_around_a_shape_there() { /// from it) doesn't make the paragraph going into that column be laid out again. #[test] fn paragraph_continued_into_next_column_ignores_shapes_well_clear_of_it() { + crate::test_fonts(); let d = column_wrap_doc(2, Some(page_shape(0.0, 300.0, 20.0, 100.0)), 450); WRAP_RELAYS.with(|c| c.set(0)); let l = lay(&d); @@ -11656,6 +11915,7 @@ fn paragraph_continued_into_next_column_ignores_shapes_well_clear_of_it() { /// (reaching below its body) doesn't push them aside there. #[test] fn paragraph_continued_onto_next_page_is_broken_for_that_page() { + crate::test_fonts(); // On page 1 from 640 down past the page bottom, at the left of the text. let d = column_wrap_doc(1, Some(page_shape(72.0, 640.0, 150.0, 150.0)), 900); let l = lay(&d); @@ -11676,6 +11936,7 @@ fn paragraph_continued_onto_next_page_is_broken_for_that_page() { /// the first: the paragraph isn't laid out again (no wrap relay), and both pieces share one layout. #[test] fn paragraph_continued_into_next_column_without_shapes_is_not_laid_out_again() { + crate::test_fonts(); let d = column_wrap_doc(2, None, 450); WRAP_RELAYS.with(|c| c.set(0)); let l = lay(&d); @@ -11696,6 +11957,7 @@ fn paragraph_continued_into_next_column_without_shapes_is_not_laid_out_again() { /// when its lines in column 2 are broken again around a shape there. #[test] fn paragraph_own_float_is_not_replaced_when_its_rest_wraps_in_the_next_column() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap}; let r = Rect::new(340.0, 300.0, 100.0, 100.0); let mut d = column_wrap_doc(2, Some(page_shape(r.x, r.y, r.w, r.h)), 450); @@ -11762,6 +12024,7 @@ fn text_clusters(pl: &ParaLayout) -> Vec<(usize, usize, Vec, f32, f32)> { /// caret, selection and hit testing (#1581). #[test] fn caps_draw_sharp_s_as_two_capitals_in_one_cluster() { + crate::test_fonts(); let width = |pl: &ParaLayout| text_clusters(pl).iter().map(|c| c.4).sum::(); let plain_strase = { let d = Document::from_text("STRASE"); @@ -11809,6 +12072,7 @@ fn caps_draw_sharp_s_as_two_capitals_in_one_cluster() { /// A caps run in a symbol font draws each code as its symbol: ß never becomes two glyphs (#1581). #[test] fn caps_in_a_symbol_font_never_expand_characters() { + crate::test_fonts(); let pl = strasse_with(&|c| { c.caps = Some(true); c.font = Some("Wingdings".into()); @@ -11826,6 +12090,7 @@ fn caps_in_a_symbol_font_never_expand_characters() { /// is the page's last block. A page with one block keeps it at the top. #[test] fn section_vertical_alignment_top_center_justify_bottom() { + crate::test_fonts(); use wordcraft_doc::section::PageVAlign; let para = |text: &str| { let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); @@ -11875,6 +12140,7 @@ fn section_vertical_alignment_top_center_justify_bottom() { /// section's (Justified then Top: spread; Top then Justified: stays at the top). #[test] fn a_sections_last_page_keeps_its_own_vertical_alignment() { + crate::test_fonts(); use wordcraft_doc::section::{PageVAlign, SectionProps}; let para = |text: &str| { let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); @@ -11902,6 +12168,7 @@ fn a_sections_last_page_keeps_its_own_vertical_alignment() { /// after inside the text area, so "Three" with 72 pt after sits 72 pt above the bottom margin. #[test] fn justified_page_keeps_the_last_paragraphs_space_after() { + crate::test_fonts(); let mut d = Document::new(); d.body = ["One", "Two", "Three"] .iter() @@ -11924,6 +12191,7 @@ fn justified_page_keeps_the_last_paragraphs_space_after() { /// #1320: a justified page with several newspaper columns stays at the top (not spread). #[test] fn justified_multi_column_pages_stay_at_the_top() { + crate::test_fonts(); let laid = |va| { let mut d = two_column_section(wordcraft_doc::section::SectionStart::NextPage, Vec::new()); d.para_mut(StoryRef::Body, &Path::top(9)).unwrap().section.as_mut().unwrap().valign = va; @@ -11946,6 +12214,7 @@ fn justified_multi_column_pages_stay_at_the_top() { /// it, and a deletion that isn't left out keeps its line. #[test] fn a_paragraph_left_out_whole_takes_no_room() { + crate::test_fonts(); let mut d = Document::from_text("One\nGone\nThree"); d.format_range(&Pos::body(1, 0), &Pos::body(2, 0), &|c| c.del = Some(0)).unwrap(); let three_top = @@ -11965,6 +12234,7 @@ fn a_paragraph_left_out_whole_takes_no_room() { /// lines across the page at its zero height). #[test] fn a_paragraph_left_out_whole_draws_no_borders_or_shading() { + crate::test_fonts(); let mut d = Document::from_text("One\nGone\nThree"); let line = Some(Border::single(1.0)); d.format_paragraphs(&Pos::body(1, 0), &Pos::body(1, 0), &|p| { @@ -11983,6 +12253,7 @@ fn a_paragraph_left_out_whole_draws_no_borders_or_shading() { #[test] fn row_borders_stay_on_row() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(2, 2, 200.0); t.props.style = None; @@ -12016,6 +12287,7 @@ fn triple_table_borders_draw_triple_lines() { #[test] fn row_nil_overrides_style() { + crate::test_fonts(); let mut d = Document::from_text("before\nafter"); let mut t = Table::new(1, 1, 200.0); t.rows[0].props.exceptions = Some(Box::new(wordcraft_doc::props::TableProps { @@ -12030,6 +12302,7 @@ fn row_nil_overrides_style() { /// Continued text keeps the cell's border inset, as on its first fragment. #[test] fn split_cell_reuses_top_inset() { + crate::test_fonts(); use wordcraft_doc::props::{HeightRule, LineSpacing}; for (width, margin) in [(0.0, 0.0), (0.5, 0.0), (2.0, 0.0), (0.0, 6.0), (0.5, 6.0), (2.0, 6.0)] { for spacing in [LineSpacing::Exactly(10.0), LineSpacing::AtLeast(10.0)] { @@ -12081,6 +12354,7 @@ fn split_cell_reuses_top_inset() { /// Images and their floating hit areas move together without changing size or crop. #[test] fn split_cell_moves_image_frame() { + crate::test_fonts(); use crate::table::{CellPart, RowLayout, split_row}; use wordcraft_doc::para::Wrap; for top in [0.0, 0.5, 6.5, 8.0] { @@ -12360,6 +12634,7 @@ fn row_keep_pages(d: &Document) -> Vec { #[test] fn kept_row_moves_with_next() { + crate::test_fonts(); for rule in [wordcraft_doc::props::HeightRule::AtLeast, wordcraft_doc::props::HeightRule::Exact] { let mut d = row_keep_doc(); d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height_rule = rule; @@ -12372,6 +12647,7 @@ fn kept_row_moves_with_next() { #[test] fn kept_row_allows_next_split() { + crate::test_fonts(); let mut d = row_keep_doc(); let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); t.rows[1].props.height = None; @@ -12384,6 +12660,7 @@ fn kept_row_allows_next_split() { #[test] fn kept_rows_move_as_chain() { + crate::test_fonts(); let mut d = row_keep_doc(); d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(170.0)); let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); @@ -12401,6 +12678,7 @@ fn kept_rows_move_as_chain() { #[test] fn oversized_kept_rows_finish() { + crate::test_fonts(); let mut d = row_keep_doc(); d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height = Some(400.0); let l = lay(&d); @@ -12410,6 +12688,7 @@ fn oversized_kept_rows_finish() { #[test] fn kept_row_can_split_itself() { + crate::test_fonts(); let mut d = row_keep_doc(); let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); t.rows[0].props.height = None; @@ -12425,6 +12704,7 @@ fn kept_row_can_split_itself() { #[test] fn first_cell_controls_keep() { + crate::test_fonts(); for (first, last, pages) in [(true, false, [1, 1]), (false, true, [0, 1]), (true, true, [1, 1]), (false, false, [0, 1])] { let mut d = row_keep_doc(); let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); @@ -12444,6 +12724,7 @@ fn first_cell_controls_keep() { #[test] fn first_para_controls_row_keep() { + crate::test_fonts(); let mut d = row_keep_doc(); let mut p = Paragraph::with_text("Last paragraph", Default::default()); p.props = d.para(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.clone(); @@ -12457,6 +12738,7 @@ fn first_para_controls_row_keep() { #[test] fn nested_keep_leaves_outer_row() { + crate::test_fonts(); let mut d = row_keep_doc(); let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); t.rows[0].props.height = Some(60.0); @@ -12470,6 +12752,7 @@ fn nested_keep_leaves_outer_row() { #[test] fn kept_prefix_stays_together() { + crate::test_fonts(); let mut d = row_keep_doc(); let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap(); t.rows[1].props.height = Some(400.0); @@ -12484,6 +12767,7 @@ fn kept_prefix_stays_together() { /// even when its anchor paragraph has no inline picture (synthetic two-cell probe). #[test] fn cell_frame_centres_empty() { + crate::test_fonts(); use wordcraft_doc::para::{Anchor, Float, Wrap}; use wordcraft_doc::props::{HeightRule, VAlign}; let mut d = Document::new(); @@ -12517,6 +12801,7 @@ fn cell_frame_centres_empty() { #[test] fn section_start_summed_spacing() { + crate::test_fonts(); for summed in [false, true] { let mut d = Document::from_text("Section end\nSection start"); d.settings.sum_paragraph_spacing = summed; @@ -12541,6 +12826,7 @@ fn section_start_summed_spacing() { #[test] fn partial_margins_inherit() { + crate::test_fonts(); let height = |top, bottom| { let mut table = Table::new(1, 1, 200.0); table.props.style = None; @@ -12572,6 +12858,7 @@ fn partial_margins_inherit() { /// Exact line pitch keeps the text baseline even when an equation has a deep denominator. #[test] fn exact_math_keeps_run_baseline() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; for display in [false, true] { for spacing in [LineSpacing::Exactly(12.0), LineSpacing::AtLeast(12.0), LineSpacing::Multiple(1.0)] { @@ -12624,6 +12911,7 @@ fn exact_math_keeps_run_baseline() { #[test] fn float_font_keeps_line_height() { + crate::test_fonts(); use wordcraft_doc::CharProps; use wordcraft_doc::para::{Float, Wrap}; @@ -12648,6 +12936,7 @@ fn float_font_keeps_line_height() { /// Native ink inside a group is drawn as drawing-level ink is: its fallback picture, in its place. #[test] fn ink_member_of_a_group_draws_its_fallback_picture() { + crate::test_fonts(); use crate::display::Draw; use std::sync::Arc; use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind}; @@ -12695,6 +12984,7 @@ fn ink_member_of_a_group_draws_its_fallback_picture() { /// again without clearing the cache. #[test] fn the_word_being_typed_has_no_spelling_squiggle() { + crate::test_fonts(); let d = Document::from_text("helo wrld\nfine text here"); let rev = d.para(StoryRef::Body, &Path::top(0)).unwrap().rev; let marks = |l: &DocLayout, grammar: bool| -> Vec { @@ -12757,6 +13047,7 @@ fn cell_break_doc(text: &str) -> Document { #[test] fn cell_breaks_take_no_height() { + crate::test_fonts(); let height = |d: &Document| { lay(d).pages[0] .items @@ -12797,6 +13088,7 @@ fn cell_breaks_take_no_height() { #[test] fn cell_break_cache_is_scoped() { + crate::test_fonts(); let mut d = cell_break_doc("\u{000c}Heading"); let Block::Table(t) = &*d.body[0] else { panic!("table") }; let p = t.rows[0].cells[0].blocks[0].clone(); @@ -12806,6 +13098,7 @@ fn cell_break_cache_is_scoped() { #[test] fn cell_breaks_with_bookmarks() { + crate::test_fonts(); for offset in [0, 1, 3] { let mut d = cell_break_doc("\u{000c}\u{000c}\u{000c}Heading"); let Block::Table(t) = Arc::make_mut(&mut d.body[0]) else { panic!("table") }; @@ -12832,6 +13125,7 @@ fn cell_breaks_with_bookmarks() { #[test] fn cell_break_only_is_marker() { + crate::test_fonts(); let d = cell_break_doc("\u{000c}"); let l = lay(&d); let para = l.pages[0] @@ -12847,6 +13141,7 @@ fn cell_break_only_is_marker() { #[test] fn subpoint_grid_keeps_widths() { + crate::test_fonts(); for gap in [0.05, 0.5, 1.0, 3.0] { let mut d = Document::new(); let mut table = Table::new(1, 3, 300.0); @@ -12875,6 +13170,7 @@ fn subpoint_grid_keeps_widths() { #[test] fn invalid_grid_keeps_fallback() { + crate::test_fonts(); for width in [0.0, -1.0, f32::NAN, f32::INFINITY] { let mut d = Document::new(); let mut table = Table::new(1, 2, 300.0); @@ -12900,6 +13196,7 @@ fn invalid_grid_keeps_fallback() { #[test] fn tiny_grid_scales_to_width() { + crate::test_fonts(); for unit in [0.05, 0.25, 0.5, f32::MIN_POSITIVE, f32::from_bits(1)] { for ratio in [1.0, 3.0] { let mut d = Document::new(); @@ -12946,6 +13243,7 @@ fn absolute_footer(text: &str, height: f32) -> Document { #[test] fn footer_absolute_height() { + crate::test_fonts(); let mut base = absolute_footer("", 20.0); let id = base.last_section.footers.default.unwrap(); base.parts.get_mut(&id).unwrap().blocks.remove(1); @@ -12982,6 +13280,7 @@ fn footer_absolute_height() { #[test] fn footer_float_wraps_body() { + crate::test_fonts(); use wordcraft_doc::props::LineSpacing; const FLOAT_TOP: f32 = 100.0; const FLOAT_RIGHT: f32 = 468.0; @@ -13025,6 +13324,7 @@ fn footer_float_wraps_body() { #[test] fn footer_table_wrap_bounds() { + crate::test_fonts(); use wordcraft_doc::para::Anchor; use wordcraft_doc::props::{HeightRule, LineSpacing, TableFloat}; const FLOAT_TOP: f32 = 100.0; @@ -13055,6 +13355,7 @@ fn footer_table_wrap_bounds() { #[test] fn header_table_collision() { + crate::test_fonts(); use wordcraft_doc::para::Anchor; use wordcraft_doc::props::{HeightRule, TableFloat}; @@ -13120,6 +13421,7 @@ fn band_row_table(top: Option) -> Table { #[test] fn shared_band_row_minimum() { + crate::test_fonts(); let hidden = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }; let shown = Border { style: BorderStyle::Single, width: SHARED_BAND, color: None, space: 0.0 }; // The previous row already paints the shared edge. Its band still clears the next cell. @@ -13141,6 +13443,7 @@ fn shared_band_row_minimum() { #[test] fn shared_band_carries_heading() { + crate::test_fonts(); use wordcraft_doc::props::{HeightRule, LineSpacing}; for (page_height, heading_page) in [(150.0, 1), (156.0, 0)] { let mut doc = Document::new(); @@ -13180,6 +13483,7 @@ fn shared_band_carries_heading() { #[test] fn shared_band_exact_rows() { + crate::test_fonts(); let mut table = band_row_table(None); for row in &mut table.rows { row.props.height_rule = wordcraft_doc::props::HeightRule::Exact; @@ -13200,6 +13504,7 @@ fn shared_band_exact_rows() { #[test] fn shared_band_borderless() { + crate::test_fonts(); let mut table = band_row_table(None); for row in &mut table.rows { row.cells[0].props.borders = Some(Borders::default()); @@ -13221,6 +13526,7 @@ fn shared_band_borderless() { #[test] fn shared_band_merge_boundary() { + crate::test_fonts(); use wordcraft_doc::props::VMerge; let mut table = band_row_table(None); table.rows[0].cells[0].props.vmerge = VMerge::Restart; @@ -13241,6 +13547,7 @@ fn shared_band_merge_boundary() { #[test] fn shared_band_grid_spans() { + crate::test_fonts(); let mut table = band_row_table(None); table.rows.truncate(2); table.grid = vec![100.0; 3]; @@ -13274,6 +13581,7 @@ fn shared_band_grid_spans() { #[test] fn caret_height_ignores_line_spacing() { + crate::test_fonts(); // #2920: extra line spacing is leading below the text; the caret stays the height of the font. use wordcraft_doc::props::LineSpacing; let caret_height = |spacing: Option| { diff --git a/crates/layout/src/tests/autospace.rs b/crates/layout/src/tests/autospace.rs index ce54e7c5fc..a5f335fb4d 100644 --- a/crates/layout/src/tests/autospace.rs +++ b/crates/layout/src/tests/autospace.rs @@ -50,6 +50,7 @@ fn quarter(d: &Document) -> f32 { #[test] fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() { + crate::test_fonts(); let mut d = Document::from_text("法院94年A字"); let q = quarter(&d); assert!(q > 0.0); @@ -65,6 +66,7 @@ fn digits_and_latin_beside_asian_text_get_a_quarter_of_the_font_size() { #[test] fn the_space_crosses_runs_and_takes_the_asian_size() { + crate::test_fonts(); let mut d = Document::from_text("漢A漢"); let size = |s: f32| move |c: &mut CharProps| c.size = Some(s); let p = para0(&mut d); @@ -76,6 +78,7 @@ fn the_space_crosses_runs_and_takes_the_asian_size() { #[test] fn spaces_punctuation_hangul_and_latin_alone_get_nothing() { + crate::test_fonts(); for text in ["例 a", "5 例", "5、", "、8", "abc 123", "漢한", "한A", "漢%", "(A)"] { let d = Document::from_text(text); assert!(gaps(&d).iter().all(|g| *g == 0.0), "{text}: {:?}", gaps(&d)); @@ -84,6 +87,7 @@ fn spaces_punctuation_hangul_and_latin_alone_get_nothing() { #[test] fn a_field_result_is_classed_by_the_text_it_shows() { + crate::test_fonts(); let mut d = Document::from_text("漢漢"); let field = InlineObject::Field { instr: "MERGEFIELD n".into(), result: "12".into(), locked: false, dirty: false, code: false, form: String::new() }; @@ -99,6 +103,7 @@ fn a_field_result_is_classed_by_the_text_it_shows() { #[test] fn a_character_grid_the_paragraph_snaps_to_turns_it_off() { + crate::test_fonts(); let with = |kind: DocGridType, snap: bool| { let mut d = Document::from_text("法院94年"); d.last_section.doc_grid = Some(DocGrid { kind, line_pitch: 18.0, char_space: 0 }); @@ -116,6 +121,7 @@ fn a_character_grid_the_paragraph_snaps_to_turns_it_off() { #[test] fn the_space_takes_no_room_at_the_end_of_a_justified_line() { + crate::test_fonts(); let text = "abcdef 1漢漢漢"; let mut d = Document::from_text(text); let pl = first_para(&lay(&d)); diff --git a/crates/layout/src/tests/bidi.rs b/crates/layout/src/tests/bidi.rs index 35c7276ec4..6ca2a43fd3 100644 --- a/crates/layout/src/tests/bidi.rs +++ b/crates/layout/src/tests/bidi.rs @@ -59,6 +59,7 @@ fn at(s: &str, n: usize) -> usize { #[test] fn visual_order_follows_uax9_rule_l2() { + crate::test_fonts(); // The example from UAX #9 / unicode-bidi: levels 0 0 0 1 1 1 2 2. assert_eq!(visual_order(&[0, 0, 0, 1, 1, 1, 2, 2]), vec![0, 1, 2, 6, 7, 5, 4, 3]); assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]); @@ -70,6 +71,7 @@ fn visual_order_follows_uax9_rule_l2() { #[test] fn levels_for_persian_with_latin_and_numbers() { + crate::test_fonts(); assert!(bidi_levels("plain English", false).is_empty(), "pure LTR paragraphs skip the algorithm"); let s = "سلام Word ۱۴۰۳"; let lv = bidi_levels(s, true); @@ -85,6 +87,7 @@ fn levels_for_persian_with_latin_and_numbers() { #[test] fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() { + crate::test_fonts(); let s = "سلام دنیا"; let d = doc_of(&[(s, true)]); let l = lay(&d); @@ -101,6 +104,7 @@ fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() { #[test] fn ltr_paragraph_with_embedded_persian_word() { + crate::test_fonts(); let s = "Hello سلام world"; let d = doc_of(&[(s, false)]); let l = lay(&d); @@ -111,6 +115,7 @@ fn ltr_paragraph_with_embedded_persian_word() { #[test] fn numbers_and_latin_inside_persian_keep_their_order() { + crate::test_fonts(); // "Price 250 toman" in Persian, with a Latin product name and Persian punctuation. let s = "قیمت 250 تومان برای Word، امسال ۱۴۰۳."; let d = doc_of(&[(s, true)]); @@ -132,6 +137,7 @@ fn numbers_and_latin_inside_persian_keep_their_order() { #[test] fn caret_and_hit_agree_in_bidi_text() { + crate::test_fonts(); for (s, rtl) in [("سلام world ۱۲۳", true), ("Hello سلام دنیا end", false), ("کتاب «WordCraft» (نسخه ۲)", true)] { let d = doc_of(&[(s, rtl)]); let l = lay(&d); @@ -152,6 +158,7 @@ fn caret_and_hit_agree_in_bidi_text() { #[test] fn visual_steps_cover_the_line_in_order() { + crate::test_fonts(); let s = "سلام world"; let d = doc_of(&[(s, true)]); let l = lay(&d); @@ -182,6 +189,7 @@ fn visual_steps_cover_the_line_in_order() { #[test] fn selection_of_mixed_text_can_be_two_pieces() { + crate::test_fonts(); // Logical range "lo wo" of "Hello سلام world"? Select across the Persian word's start: // "lo سل" is two visual pieces (the end of Hello, and the right part of سلام). let s = "Hello سلام world"; @@ -196,6 +204,7 @@ fn selection_of_mixed_text_can_be_two_pieces() { #[test] fn rtl_indents_and_alignment_are_measured_from_the_right() { + crate::test_fonts(); let s = "متن فارسی"; let mut d = doc_of(&[(s, true), (s, true), (s, true)]); let p0 = Pos::body(0, 0); @@ -214,6 +223,7 @@ fn rtl_indents_and_alignment_are_measured_from_the_right() { #[test] fn rtl_list_label_sits_on_the_right() { + crate::test_fonts(); let mut d = doc_of(&[("مورد اول", true), ("مورد دوم", true)]); let id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered); d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p: &mut ParaProps| { @@ -235,6 +245,7 @@ fn rtl_list_label_sits_on_the_right() { #[test] fn rtl_empty_and_trailing_lines_mirror() { + crate::test_fonts(); let d = doc_of(&[("", true)]); let l = lay(&d); assert!((caret_x(&l, 0, 0) - 540.0).abs() < 0.5, "empty right-to-left paragraph: caret at the right margin"); @@ -248,6 +259,7 @@ fn rtl_empty_and_trailing_lines_mirror() { #[test] fn bidi_display_draws_every_glyph_inside_the_line() { + crate::test_fonts(); let s = "این یک متن آزمایشی با English و عدد ۱۲۳ است. ".repeat(12); let d = doc_of(&[(&s, true)]); let l = lay(&d); @@ -274,6 +286,7 @@ fn bidi_display_draws_every_glyph_inside_the_line() { #[test] fn hostile_bidi_text_never_panics() { + crate::test_fonts(); // Explicit embeddings, isolates, unmatched pops, marks with no base, lone surrogates' stand-ins. let junk = [ "\u{202B}\u{202B}\u{202C}\u{202C}\u{202C}abc\u{2067}سلام\u{2069}\u{2069}", @@ -301,6 +314,7 @@ fn hostile_bidi_text_never_panics() { /// A face that has Arabic letters (installed system fonts), or `None` to skip. fn arabic_face() -> Option { + crate::test_fonts(); wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ')) } @@ -319,6 +333,7 @@ fn cluster_glyphs(glyphs: &[wordcraft_fonts::ShapedGlyph]) -> Vec> { #[test] fn persian_letters_join_and_brackets_mirror() { + crate::test_fonts(); let Some(face) = arabic_face() else { eprintln!("skipped: no font with Persian letters installed"); return; @@ -355,6 +370,7 @@ fn persian_letters_join_and_brackets_mirror() { #[test] fn persian_text_in_a_latin_font_stays_in_one_fallback_face() { + crate::test_fonts(); if arabic_face().is_none() { eprintln!("skipped: no font with Persian letters installed"); return; @@ -373,6 +389,7 @@ fn persian_text_in_a_latin_font_stays_in_one_fallback_face() { #[test] fn glyph_runs_carry_their_text_for_pdf_export() { + crate::test_fonts(); let s = "سلام WordCraft می‌خواهم"; let d = doc_of(&[(s, true)]); let l = lay(&d); @@ -402,6 +419,7 @@ fn glyph_runs_carry_their_text_for_pdf_export() { #[test] fn character_border_boxes_a_whole_rtl_word() { + crate::test_fonts(); let s = "کتاب خوب"; let mut d = doc_of(&[(s, true)]); let b = wordcraft_doc::props::Border { space: 0.0, ..wordcraft_doc::props::Border::single(0.5) }; @@ -426,6 +444,7 @@ fn character_border_boxes_a_whole_rtl_word() { #[test] fn inline_picture_in_rtl_text_sits_in_its_place() { + crate::test_fonts(); // "متن [picture] بیشتر": in a right-to-left paragraph the picture is drawn where its cluster // is on the line (left of the first word), not at its logical x. let s = "متن بیشتر"; @@ -464,6 +483,7 @@ fn inline_picture_in_rtl_text_sits_in_its_place() { /// margin and border are drawn on its right. Clicks find cells by where they are drawn. #[test] fn rtl_table_lays_its_columns_out_right_to_left() { + crate::test_fonts(); use wordcraft_doc::props::{Border, Borders}; use wordcraft_doc::{Path, Table}; let mut d = Document::from_text("before\nafter"); @@ -511,6 +531,7 @@ fn rtl_table_lays_its_columns_out_right_to_left() { /// first column at the right, and the line between them stays midway. #[test] fn rtl_section_puts_its_first_column_on_the_right() { + crate::test_fonts(); use wordcraft_doc::section::Columns; let text = "Columns flow from the right edge in a right-to-left section. ".repeat(150); let mut d = Document::from_text(&text); @@ -531,6 +552,7 @@ fn rtl_section_puts_its_first_column_on_the_right() { #[test] fn list_label_moves_with_its_text_when_the_paragraph_is_aligned() { + crate::test_fonts(); // Issue #1159: centring or end-aligning a list item moved the text and left the bullet at the // margin. In Word the label stays attached to the start of the text, in both directions. for (rtl, text) in [(false, "alpha beta"), (true, "مورد اول")] { diff --git a/crates/layout/src/tests/complex.rs b/crates/layout/src/tests/complex.rs index 7936784243..206f48875a 100644 --- a/crates/layout/src/tests/complex.rs +++ b/crates/layout/src/tests/complex.rs @@ -29,6 +29,7 @@ const MYANMAR: &str = "\u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}\u{1005}\ #[test] fn myanmar_syllables_are_single_clusters() { + crate::test_fonts(); let l = lay(&Document::from_text(MYANMAR)); let pl = para_layout(&l, 0); let starts: Vec = pl.clusters.iter().map(|c| c.start).collect(); @@ -46,6 +47,7 @@ fn myanmar_syllables_are_single_clusters() { #[test] fn myanmar_without_spaces_wraps_between_syllables() { + crate::test_fonts(); let text = MYANMAR.repeat(80); let l = lay(&Document::from_text(&text)); let pl = para_layout(&l, 0); @@ -66,11 +68,13 @@ fn myanmar_without_spaces_wraps_between_syllables() { /// A face with Myanmar letters (an installed system font), or `None` to skip. fn myanmar_face() -> Option { + crate::test_fonts(); wordcraft_fonts::FontDb::global().fallback_for('\u{1000}', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.has_layout_tables()) } #[test] fn myanmar_prevowel_is_drawn_before_its_consonant() { + crate::test_fonts(); let Some(face) = myanmar_face() else { eprintln!("skipped: no Myanmar font with OpenType tables installed"); return; @@ -92,6 +96,7 @@ fn myanmar_prevowel_is_drawn_before_its_consonant() { #[test] fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() { + crate::test_fonts(); if myanmar_face().is_none() { eprintln!("skipped: no Myanmar font with OpenType tables installed"); return; @@ -115,12 +120,14 @@ const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู /// Is a font with Thai letters installed? `false` skips the test. fn thai_font_installed() -> bool { + crate::test_fonts(); wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some() } /// Is a Thai font with Latin letters and digits installed (Tahoma, Leelawadee UI, Thonburi, the /// TLWG fonts…)? Noto Sans Thai alone has none, so no face covers [`THAI_RUN`]: `false` skips. fn thai_latin_font_installed() -> bool { + crate::test_fonts(); let face = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face; THAI_RUN.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c)) } @@ -139,6 +146,7 @@ fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts: #[test] fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() { + crate::test_fonts(); if !thai_latin_font_installed() { eprintln!("skipped: no Thai font with Latin letters installed"); return; @@ -161,6 +169,7 @@ fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() { #[test] fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() { + crate::test_fonts(); if !thai_latin_font_installed() { eprintln!("skipped: no Thai font with Latin letters installed"); return; @@ -184,6 +193,7 @@ fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() { #[test] fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() { + crate::test_fonts(); if !thai_font_installed() { eprintln!("skipped: no Thai font installed"); return; @@ -215,6 +225,7 @@ fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() { #[test] fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() { + crate::test_fonts(); if !thai_font_installed() { eprintln!("skipped: no Thai font installed"); return; @@ -230,6 +241,7 @@ fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() { #[test] fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() { + crate::test_fonts(); if !thai_font_installed() { eprintln!("skipped: no Thai font installed"); return; @@ -251,6 +263,7 @@ fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() { #[test] fn a_missing_roman_thai_font_is_not_drawn_in_cambria() { + crate::test_fonts(); // Word draws a missing font the font table classes as roman in Cambria, but a missing Thai // font keeps its Thai substitute: the same face whether or not the table calls it roman. let db = wordcraft_fonts::FontDb::global(); diff --git a/crates/layout/src/tests/grid.rs b/crates/layout/src/tests/grid.rs index 87ae674399..7e4d0a94eb 100644 --- a/crates/layout/src/tests/grid.rs +++ b/crates/layout/src/tests/grid.rs @@ -42,6 +42,7 @@ fn lines(l: &DocLayout) -> (usize, Vec) { #[test] fn line_grid_snaps_line_heights_and_fixes_lines_per_page() { + crate::test_fonts(); let text = vec!["Grid line"; 80].join("\n"); let d = with_grid(&text, DocGridType::Lines, 0); let (first, hs) = lines(&lay(&d)); @@ -65,6 +66,7 @@ fn line_grid_snaps_line_heights_and_fixes_lines_per_page() { #[test] fn character_grid_places_east_asian_text_on_the_pitch() { + crate::test_fonts(); let text = "日本語の文書abc"; // The default font size plus 2.5 pt: a pitch unlike any font's advance. let lines = wordcraft_doc::SectionProps { doc_grid: Some(DocGrid { kind: DocGridType::Lines, ..Default::default() }), ..Default::default() }; diff --git a/crates/layout/src/tests/ligature.rs b/crates/layout/src/tests/ligature.rs index d2cd4b243e..7fc0d5ee58 100644 --- a/crates/layout/src/tests/ligature.rs +++ b/crates/layout/src/tests/ligature.rs @@ -44,6 +44,7 @@ fn has_fi_ligature(l: &DocLayout) -> bool { #[test] fn caret_between_the_letters_of_a_ligature_is_inside_it() { + crate::test_fonts(); let l = lay(&doc("fi")); assert!(has_fi_ligature(&l), "the default font should draw fi as a ligature"); let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2)); @@ -62,6 +63,7 @@ fn fi_width_with_spacing(sp: Option) -> (DocLayout, f32) { #[test] fn character_spacing_inside_a_ligature_is_kept() { + crate::test_fonts(); let (l0, plain) = fi_width_with_spacing(None); assert!(has_fi_ligature(&l0), "the default font should draw fi as a ligature"); for sp in [3.0_f32, -0.5] { @@ -75,6 +77,7 @@ fn character_spacing_inside_a_ligature_is_kept() { #[test] fn clicks_inside_a_ligature_land_between_its_letters() { + crate::test_fonts(); let l = lay(&doc("fi")); let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2)); let cr = l.caret(&Pos::body(0, 1)).unwrap(); @@ -88,6 +91,7 @@ fn clicks_inside_a_ligature_land_between_its_letters() { #[test] fn every_letter_of_a_three_letter_ligature_has_its_own_caret() { + crate::test_fonts(); let l = lay(&doc("ffi")); assert!(has_fi_ligature(&l), "the default font should draw ffi as a ligature"); let xs: Vec = (0..=3).map(|o| caret_x(&l, o)).collect(); @@ -96,6 +100,7 @@ fn every_letter_of_a_three_letter_ligature_has_its_own_caret() { #[test] fn a_combining_mark_stays_in_its_letters_cluster() { + crate::test_fonts(); let l = lay(&doc("fi\u{301}")); let pl = para_layout(&l); let offs = pl.line_offsets(0); @@ -104,6 +109,7 @@ fn a_combining_mark_stays_in_its_letters_cluster() { #[test] fn selection_edges_inside_a_ligature_use_the_same_positions() { + crate::test_fonts(); let d = doc("fi"); let l = lay(&d); let rects = l.selection_rects(&d, &Pos::body(0, 0), &Pos::body(0, 1), 0); @@ -134,6 +140,7 @@ fn lam_alef_ligature() -> Option { #[test] fn a_right_to_left_ligature_splits_mirrored() { + crate::test_fonts(); let Some(l) = lam_alef_ligature() else { eprintln!("skipped: no lam-alef ligature font"); return; @@ -146,6 +153,7 @@ fn a_right_to_left_ligature_splits_mirrored() { #[test] fn a_right_to_left_ligature_is_drawn_from_its_left_edge() { + crate::test_fonts(); // The glyph stays on lam, the rightmost letter; it must still start at alef's left edge. let Some(l) = lam_alef_ligature() else { eprintln!("skipped: no lam-alef ligature font"); @@ -162,6 +170,7 @@ fn a_right_to_left_ligature_is_drawn_from_its_left_edge() { #[test] fn an_emoji_zwj_sequence_stays_one_cluster() { + crate::test_fonts(); // 👩‍💻: woman, zero-width joiner, laptop — one grapheme, one caret stop on each side. let t = "a\u{1F469}\u{200D}\u{1F4BB}b"; let l = lay(&Document::from_text(t)); @@ -269,6 +278,7 @@ fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundari #[test] fn an_indic_conjunct_stays_one_cluster() { + crate::test_fonts(); // क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it. let l = lay(&Document::from_text("\u{915}\u{94D}\u{937}")); let offs = para_layout(&l).line_offsets(0); @@ -277,6 +287,7 @@ fn an_indic_conjunct_stays_one_cluster() { #[test] fn a_long_word_never_wraps_inside_a_ligature() { + crate::test_fonts(); // A narrow page forces an emergency break in the unbroken word; whatever the offset, a line // never ends between a ligature's glyph and its glyphless letters. let mut seen_ligature = false; @@ -299,6 +310,7 @@ fn a_long_word_never_wraps_inside_a_ligature() { #[test] fn a_hyphenation_point_never_splits_a_ligature() { + crate::test_fonts(); // Soft hyphens sit between the letters of "ffi"; a line never ends between a ligature's glyph // and its glyphless letters, whether or not the soft hyphen leaves the ligature intact. for k in 0..24 { diff --git a/crates/layout/src/tests/nbsp.rs b/crates/layout/src/tests/nbsp.rs index 36b9ccb9b1..64144585bd 100644 --- a/crates/layout/src/tests/nbsp.rs +++ b/crates/layout/src/tests/nbsp.rs @@ -6,6 +6,7 @@ use crate::{Document, LayoutCache, LayoutOptions, Placed, layout}; /// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp". /// Built from the bundled font so the test needs no installed font. fn wide_nbsp_family() -> &'static str { + crate::test_fonts(); const FAMILY: &str = "Wnbsp"; let inter = wordcraft_fonts::word::resolve("Inter", false, false).face; let mut data = inter.data().to_vec(); @@ -54,6 +55,7 @@ fn line_width(text: &str) -> f32 { #[test] fn a_no_break_space_is_as_wide_as_a_space() { + crate::test_fonts(); let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face; assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name"); assert!( diff --git a/crates/layout/src/tests/numeral.rs b/crates/layout/src/tests/numeral.rs index b8cabfee00..a1052dae84 100644 --- a/crates/layout/src/tests/numeral.rs +++ b/crates/layout/src/tests/numeral.rs @@ -65,6 +65,7 @@ fn gids_pair(text: &str, a: (bool, NumeralMode), b: (bool, NumeralMode)) -> (Vec #[test] fn parse_accepts_the_four_modes_any_case() { + crate::test_fonts(); assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic)); assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi)); assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context)); @@ -78,6 +79,7 @@ fn parse_accepts_the_four_modes_any_case() { #[test] fn map_substitutes_ascii_digits_only() { + crate::test_fonts(); for d in '0'..='9' { let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap(); assert_eq!(NumeralMode::Hindi.map(d, false), hindi); @@ -101,6 +103,7 @@ fn map_substitutes_ascii_digits_only() { #[test] fn resolve_uses_the_given_system_locale() { + crate::test_fonts(); for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] { assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved"); } @@ -115,6 +118,7 @@ fn resolve_uses_the_given_system_locale() { #[test] fn context_mode_on_a_very_long_digit_run_stays_linear() { + crate::test_fonts(); // Every digit used to scan back over all the digits and spaces before it (O(n²)). let text = format!("سنة {}", "1 ".repeat(100_000)); let start = std::time::Instant::now(); @@ -129,6 +133,7 @@ fn context_mode_on_a_very_long_digit_run_stays_linear() { #[test] fn hindi_mode_reshapes_digits_but_keeps_offsets() { + crate::test_fonts(); let text = "Year 2024!"; let latin = lay_opts(text, false, NumeralMode::Arabic); let hindi = lay_opts(text, false, NumeralMode::Hindi); @@ -150,6 +155,7 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() { #[test] fn arabic_mode_keeps_typed_hindi_digits() { + crate::test_fonts(); let text = "عام ٢٠٢٤"; let (hindi, arabic) = gids_pair(text, (true, NumeralMode::Hindi), (true, NumeralMode::Arabic)); assert_eq!(hindi, arabic, "typed Hindi digits display as typed in Arabic mode"); @@ -157,6 +163,7 @@ fn arabic_mode_keeps_typed_hindi_digits() { #[test] fn context_mode_follows_run_direction() { + crate::test_fonts(); // Right-to-left paragraph: digits behave like Hindi mode. let rtl = "سنة 2024"; let (ctx, hin) = gids_pair(rtl, (true, NumeralMode::Context), (true, NumeralMode::Hindi)); @@ -173,6 +180,7 @@ fn context_mode_follows_run_direction() { #[test] fn remap_range_maps_display_bytes_to_original() { + crate::test_fonts(); use crate::para::remap_range; // "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it. let orig = "a5ب"; diff --git a/crates/layout/src/tests/pages.rs b/crates/layout/src/tests/pages.rs index aa87d7eb31..e55c269240 100644 --- a/crates/layout/src/tests/pages.rs +++ b/crates/layout/src/tests/pages.rs @@ -35,6 +35,7 @@ fn body_left(l: &DocLayout) -> Vec { #[test] fn mirrored_margins_put_the_inside_margin_toward_the_binding() { + crate::test_fonts(); // #452: inside 108 (plus a 10pt gutter), outside 36. Odd pages: inside on the left; even // pages: on the right. let mut d = Document::from_text(&"Body line\n".repeat(90)); @@ -55,6 +56,7 @@ fn mirrored_margins_put_the_inside_margin_toward_the_binding() { #[test] fn inside_and_outside_floats_swap_sides_on_even_pages() { + crate::test_fonts(); // #1181: shapes Word places in the outside margin of a book (mirrored margins) all came out // in the right margin. Inside 108 plus a 10pt gutter, outside 36; the paragraph on page 2 // carries the same six shapes as page 1's, told apart by their widths. @@ -124,6 +126,7 @@ fn inside_and_outside_floats_swap_sides_on_even_pages() { #[test] fn outside_floating_tables_swap_sides_on_even_pages() { + crate::test_fonts(); // #1181, for a floating table (`w:tblpXSpec="outside"`): on page 2 of a book the outside is // the left. One positioned from the page edge stays there on both pages. use wordcraft_doc::para::{Anchor, FloatAlign}; @@ -159,6 +162,7 @@ fn outside_floating_tables_swap_sides_on_even_pages() { #[test] fn a_tall_footer_pushes_the_body_up() { + crate::test_fonts(); // #453: an eight-line footer reaches far above the 72pt bottom margin. let mut d = Document::from_text(&"Body line\n".repeat(60)); let id = d.add_part(PartKind::Footer, paras("Footer line", 8)); @@ -184,6 +188,7 @@ fn a_tall_footer_pushes_the_body_up() { #[test] fn a_linked_tall_header_pushes_the_next_sections_body_down() { + crate::test_fonts(); // #454: section 2 has no header of its own and shows section 1's (link to previous). let mut d = Document::from_text("First section\nSecond section"); let id = d.add_part(PartKind::Header, paras("Header line", 8)); @@ -203,6 +208,7 @@ fn a_linked_tall_header_pushes_the_next_sections_body_down() { #[test] fn long_endnotes_continue_on_new_pages() { + crate::test_fonts(); // #456: an 80-paragraph endnote after a one-line body. let mut d = Document::from_text("Body"); let id = d.add_part(PartKind::Endnote, paras("Endnote paragraph", 80)); @@ -230,6 +236,7 @@ fn long_endnotes_continue_on_new_pages() { #[test] fn caret_in_a_continued_footnote_is_found_on_its_page() { + crate::test_fonts(); // #457: one long footnote paragraph split over pages 1 and 2. let mut d = Document::from_text(&"Body text line.\n".repeat(16)); let id = d.add_part( @@ -264,6 +271,7 @@ fn caret_in_a_continued_footnote_is_found_on_its_page() { /// own pages). The footer shows each letter's last page, not the file's stale result. #[test] fn page_references_in_footers_show_their_bookmarks_page() { + crate::test_fonts(); let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: "9".into(), locked: false, dirty: false, code: false, form: String::new() }; let footer = |d: &mut Document, mark: &str| { @@ -379,6 +387,7 @@ fn mark(d: &mut Document, path: &Path, off: usize, name: &str) { /// section numbered in roman numerals are found on the page and in the format they land in. #[test] fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections() { + crate::test_fonts(); // One long paragraph over two pages, marked at its start and its end. let mut d = Document::from_text(&"Long paragraph text running on. ".repeat(160)); let n = d.para(StoryRef::Body, &Path::top(0)).unwrap().len(); @@ -423,6 +432,7 @@ fn page_references_find_bookmarks_in_split_paragraphs_tables_and_roman_sections( /// A text box in a footer resolves its page references; one in the body keeps its result. #[test] fn page_references_in_footer_text_boxes_are_resolved() { + crate::test_fonts(); use wordcraft_doc::para::{Float, ShapeKind, Wrap}; let text_box = |d: &mut Document| { let mut p = Paragraph::new(); @@ -475,9 +485,11 @@ fn page_references_in_footer_text_boxes_are_resolved() { #[test] fn widow_control_moves_a_three_line_paragraph_whole() { + crate::test_fonts(); // A three-line paragraph with room for two at the bottom of a page can't leave a widow (two // and one) nor an orphan (one and two): Word moves it whole. A four-line one splits two and // two. Sweep the paragraph down the page a point at a time and check every split. + // Shift the filler immediately before the probe, so the sweep tests partial-line room. let mut split4 = false; for (want, words) in [(3usize, 45usize), (4, 55)] { for (fill, shift) in (30usize..60).flat_map(|f| (0..15).step_by(2).map(move |s| (f, s))) { @@ -508,6 +520,7 @@ fn widow_control_moves_a_three_line_paragraph_whole() { #[test] fn footer_border_limits_body() { + crate::test_fonts(); use wordcraft_doc::props::{Border, Borders, HeightRule}; let mut d = Document::from_text(&"Body line\n".repeat(40)); d.last_section.page_h = 400.0; @@ -537,6 +550,7 @@ fn footer_border_limits_body() { /// number format is known, isn't taken for the page's own footer. #[test] fn a_roman_page_number_in_the_footer_stays_roman_with_the_page_count_in_the_body() { + crate::test_fonts(); let field = |instr: &str| InlineObject::Field { instr: instr.into(), result: String::new(), diff --git a/crates/layout/src/tests/styleref.rs b/crates/layout/src/tests/styleref.rs index b6e91f6017..e8430de4d2 100644 --- a/crates/layout/src/tests/styleref.rs +++ b/crates/layout/src/tests/styleref.rs @@ -52,6 +52,7 @@ const CHAPTERS: &[(&str, &str, bool)] = &[ #[test] fn each_page_s_header_shows_its_chapter() { + crate::test_fonts(); let d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1" \* MERGEFORMAT"#, "STYLEREF 1", "STYLEREF heading1 \\l"]); let h = headers(&d); assert_eq!(h.len(), 4); @@ -62,6 +63,7 @@ fn each_page_s_header_shows_its_chapter() { #[test] fn search_from_the_bottom_with_l() { + crate::test_fonts(); let paras = [("Heading2", "First", false), ("Heading2", "Second", false), ("Normal", "Body", false), ("Heading2", "Third", true)]; let d = doc(¶s, &[r#"STYLEREF "Heading 2""#, r#"STYLEREF "Heading 2" \l"#]); assert_eq!(headers(&d), [vec!["First", "Second"], vec!["Third", "Third"]]); @@ -69,6 +71,7 @@ fn search_from_the_bottom_with_l() { #[test] fn a_missing_style_or_no_paragraph_of_it_is_an_error() { + crate::test_fonts(); let d = doc(CHAPTERS, &[r#"STYLEREF "No Such Style""#, r#"STYLEREF "Heading 3""#, "STYLEREF 12"]); let err = styleref::NO_TEXT; assert_eq!(headers(&d)[0], [err, err, err]); @@ -77,6 +80,7 @@ fn a_missing_style_or_no_paragraph_of_it_is_an_error() { #[test] fn before_the_first_heading_the_next_one_shows() { + crate::test_fonts(); let paras = [("Normal", "Preface", false), ("Heading1", "Chapter", true)]; let d = doc(¶s, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]); assert_eq!(headers(&d), [vec!["Chapter", "below"], vec!["Chapter", "below"]]); @@ -84,6 +88,7 @@ fn before_the_first_heading_the_next_one_shows() { #[test] fn character_styles_and_paragraph_numbers() { + crate::test_fonts(); let mut d = doc(CHAPTERS, &[r#"STYLEREF Strong"#, r#"STYLEREF "Heading 1" \n"#, r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 1" \s"#]); // Bold a word on page 2 with the Strong character style. d.format_range(&Pos::body(3, 8), &Pos::body(3, 11), &|c: &mut CharProps| c.style = Some("Strong".into())).unwrap(); @@ -143,6 +148,7 @@ fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() { /// Editing a heading updates the headers that show it, through the paragraph cache. #[test] fn headers_follow_heading_edits() { + crate::test_fonts(); let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]); let mut c = LayoutCache::new(); let first = layout(&d, &mut c, &LayoutOptions::default()); @@ -158,6 +164,7 @@ fn headers_follow_heading_edits() { /// documents lay out on every keystroke); an edited heading or a changed style rebuilds it. #[test] fn style_index_is_reused_until_the_body_or_styles_change() { + crate::test_fonts(); let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#]); let mut c = LayoutCache::new(); let index = |c: &LayoutCache| c.styleref.as_ref().map(|(_, _, ix)| ix.clone()).unwrap(); @@ -186,6 +193,7 @@ fn style_index_is_reused_until_the_body_or_styles_change() { /// `STYLEREF 1` finds the style named `heading 1` when its id is localized (#1286). #[test] fn heading_level_matches_a_localized_style_id() { + crate::test_fonts(); let paras: Vec<(&str, &str, bool)> = CHAPTERS.iter().map(|(s, t, b)| (if *s == "Heading1" { "berschrift1" } else { *s }, *t, *b)).collect(); let mut d = doc(¶s, &["STYLEREF 1"]); let st = Arc::make_mut(&mut d.styles).styles.iter_mut().find(|s| s.id == "Heading1").unwrap(); @@ -200,6 +208,7 @@ fn heading_level_matches_a_localized_style_id() { /// edited heading still updates the headers that show it. #[test] fn body_edits_keep_styleref_headers_cached() { + crate::test_fonts(); let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 1""#, r#"STYLEREF "Heading 1" \p"#]); let mut c = LayoutCache::new(); let opts = LayoutOptions::default(); @@ -244,6 +253,7 @@ fn field_char_xs(d: &Document) -> Vec<(String, Vec)> { /// also in a right-to-left header paragraph. #[test] fn hebrew_styleref_result_reads_right_to_left() { + crate::test_fonts(); let d = doc(&[("Heading1", "פרק א", false), ("Normal", "Text.", false)], &[r#"STYLEREF "Heading 1""#]); let got = field_char_xs(&d); let (text, xs) = &got[0]; @@ -312,6 +322,7 @@ fn rtl_header(d: &mut Document) { /// Hebrew word in brackets in a left-to-right result draws its brackets the right way round. #[test] fn field_results_mirror_brackets_with_their_bidi_runs() { + crate::test_fonts(); let mut got = Vec::new(); // The field-code view of a PAGE field in a right-to-left header. let mut d = doc(&[("Normal", "Text.", false)], &["PAGE"]); diff --git a/crates/layout/src/tests/textbox.rs b/crates/layout/src/tests/textbox.rs index 558eb67eaa..82f07c7262 100644 --- a/crates/layout/src/tests/textbox.rs +++ b/crates/layout/src/tests/textbox.rs @@ -57,6 +57,7 @@ fn frame_of(l: &DocLayout, id: u32) -> Rect { #[test] fn align_text_and_text_direction_place_the_text_in_the_box() { + crate::test_fonts(); let at = |body: TextBody| { let mut d = Document::from_text("x"); let id = text_box(&mut d, 0, "Hi", 144.0, 144.0, body); @@ -87,6 +88,7 @@ fn align_text_and_text_direction_place_the_text_in_the_box() { #[test] fn linked_boxes_continue_the_text() { + crate::test_fonts(); let mut d = Document::from_text("one\ntwo"); let text = "Words that run on well past the first little box. ".repeat(6); let a = text_box(&mut d, 0, &text, 144.0, 48.0, TextBody::default()); @@ -125,6 +127,7 @@ fn linked_boxes_continue_the_text() { #[test] fn hostile_link_chains_stay_bounded() { + crate::test_fonts(); // Boxes linking in a circle show their own text; a box linking to itself too. let mut d = Document::from_text("one\ntwo\nthree"); let a = text_box(&mut d, 0, "A", 72.0, 36.0, TextBody::default()); @@ -142,6 +145,7 @@ fn hostile_link_chains_stay_bounded() { #[test] fn own_margins_and_resize_to_fit_keep_the_text_in_view() { + crate::test_fonts(); // #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for // its text; with Word's default margins its second line would be cut. let two = |h: f32, body: TextBody| { diff --git a/crates/layout/src/tests/vertical.rs b/crates/layout/src/tests/vertical.rs index bc5a1efb8d..34a52185e7 100644 --- a/crates/layout/src/tests/vertical.rs +++ b/crates/layout/src/tests/vertical.rs @@ -30,6 +30,7 @@ fn body_lines(l: &DocLayout) -> Vec<(f32, f32, TextDirection)> { #[test] fn top_to_bottom_section_stacks_lines_from_the_right() { + crate::test_fonts(); let d = vertical_doc(TextDirection::Down); let l = lay(&d); let page = &l.pages[0]; @@ -70,6 +71,7 @@ fn top_to_bottom_section_stacks_lines_from_the_right() { #[test] fn bottom_to_top_section_stacks_lines_from_the_left() { + crate::test_fonts(); let l = lay(&vertical_doc(TextDirection::Up)); assert!(body_lines(&l).iter().all(|(.., t)| *t == TextDirection::Up)); let c0 = l.caret(&Pos::body(0, 0)).unwrap(); @@ -81,6 +83,7 @@ fn bottom_to_top_section_stacks_lines_from_the_left() { #[test] fn east_asian_characters_stand_upright_in_top_to_bottom_text() { + crate::test_fonts(); let l = lay(&vertical_doc(TextDirection::Down)); let d = vertical_doc(TextDirection::Down); let draws = display::page_display(&d, &l.pages[0], &Default::default()); @@ -106,6 +109,7 @@ fn east_asian_characters_stand_upright_in_top_to_bottom_text() { #[test] fn headers_stay_horizontal_and_a_vertical_section_starts_a_page() { + crate::test_fonts(); use wordcraft_doc::section::SectionStart; let mut d = Document::from_text("Horizontal\nVertical"); // Section 1 ends after paragraph 0 (continuous), section 2 is vertical. diff --git a/crates/layout/src/wordart.rs b/crates/layout/src/wordart.rs index dbde133430..7fd13630df 100644 --- a/crates/layout/src/wordart.rs +++ b/crates/layout/src/wordart.rs @@ -336,6 +336,7 @@ mod tests { #[test] fn arch_baselines_lie_on_the_arc() { + crate::test_fonts(); let frame = Rect::new(10.0, 20.0, 200.0, 100.0); let w = Warp::new(&TextWarp::new("textArchUp").unwrap(), frame, 0.25).unwrap(); let (cx, cy, rx, ry) = (110.0, 70.0, 100.0, 50.0); @@ -363,6 +364,7 @@ mod tests { /// #2534: the legacy font effects become text effects, under any WordArt effects. #[test] fn legacy_font_effects_are_drawn_as_text_effects() { + crate::test_fonts(); use wordcraft_doc::CharProps; let rc = |c: CharProps| wordcraft_doc::styles::StyleSheet::default().resolve_char(None, &CharProps { size: Some(20.0), ..c }); let red = Rgb(200, 0, 0); @@ -394,6 +396,7 @@ mod tests { /// #2534: synthetic bold widens the outline, or adds one in the fill's colour. #[test] fn embolden_widens_or_adds_the_outline() { + crate::test_fonts(); let red = Rgb(200, 0, 0); let mut plain = TextEffects::default(); embolden(&mut plain, red, 20.0); @@ -418,6 +421,7 @@ mod tests { /// draws its glyphs plainly. #[test] fn effects_on_tracked_and_turned_text() { + crate::test_fonts(); use crate::display::{DisplayOptions, Draw, InsertMark, page_display}; use wordcraft_doc::props::TextDirection; use wordcraft_doc::{CharProps, Document, Paragraph, Revision, RevisionKind, para_block}; @@ -456,6 +460,7 @@ mod tests { #[test] fn flat_and_unknown_warps_draw_flat() { + crate::test_fonts(); let frame = Rect::new(0.0, 0.0, 100.0, 50.0); assert!(Warp::new(&TextWarp::new("textPlain").unwrap(), frame, 0.2).is_none()); assert!(Warp::new(&TextWarp::new("textCanUp").unwrap(), frame, 0.2).is_none()); diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 3de7878c7b..6ceadd1a42 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -184,48 +184,64 @@ impl Remote { Ok(req.to_string()) } - fn roundtrip(&mut self, line: &str) -> std::io::Result { + /// Send one line and read its reply. Once a write is attempted, any failure leaves the + /// outcome unknown: even a failed write may have delivered a complete request at EOF. + fn roundtrip(&mut self, line: &str) -> Result { let Some((reader, writer)) = self.conn.as_mut() else { - return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); + return Err(Failure::NotSent(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"))); }; - writer.write_all(line.as_bytes())?; - writer.write_all(b"\n")?; - writer.flush()?; - match read_bounded_line(reader, MAX_REPLY_LINE)? { - BoundedLine::Line(reply) => Ok(reply), - BoundedLine::Eof => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")), - BoundedLine::TooLong => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long")), - } + roundtrip_io(reader, writer, line) } - /// One request and its reply, with one retry on a new connection (the app may have - /// restarted; a new connection also reads the key again). + /// One request and its reply. A request that was not sent, or one that only reads, is tried + /// once more on a new connection (the app may have restarted; a new connection also reads + /// the key again). A mutation whose outcome is unknown is never sent again: it may have run. fn exchange(&mut self, id: u64, method: &str, params: &Value) -> Result { + self.exchange_with(id, method, params, Self::roundtrip) + } + + fn exchange_with( + &mut self, + id: u64, + method: &str, + params: &Value, + mut roundtrip: impl FnMut(&mut Self, &str) -> Result, + ) -> Result { let unreachable = |addr: &str, e: std::io::Error, connected: bool| { let retry = if connected { String::new() } else { "; will retry on each call".to_string() }; format!("WordCraft app at {addr} is not reachable: {e}{retry}") }; let mut first = true; let reply = loop { - let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() }; + let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect().map_err(Failure::NotSent) }; let result = match tried { Ok(()) => { let line = self.request_line(id, method, params)?; - self.roundtrip(&line) + roundtrip(self, &line) } Err(e) => Err(e), }; match result { Ok(r) => break r, - Err(e) => { + Err(f) => { self.conn = None; // A reply that arrived but can't be read (too long, not UTF-8): the app has // run the request, so sending it again could run a state-changing one twice. - if e.kind() == std::io::ErrorKind::InvalidData { + if let Failure::Unknown(e) = &f + && e.kind() == std::io::ErrorKind::InvalidData + { return Err(format!("bad reply from app: {e}")); } + if let Failure::Unknown(e) = &f + && !is_read_only(method, params) + { + return Err(format!( + "outcome unknown: the connection to the WordCraft app at {} failed while sending `{method}` or waiting for its reply ({e}); it may have been executed, so it is not sent again. Inspect the document to check", + self.addr + )); + } if !first { - return Err(unreachable(&self.addr, e, self.connected)); + return Err(unreachable(&self.addr, f.into_error(), self.connected)); } first = false; } @@ -235,6 +251,44 @@ impl Remote { } } +/// Shared socket exchange, also used with a writer that fails after partial delivery in tests. +fn roundtrip_io(reader: &mut impl std::io::BufRead, writer: &mut impl Write, line: &str) -> Result { + let mut out = Vec::with_capacity(line.len() + 1); + out.extend_from_slice(line.as_bytes()); + out.push(b'\n'); + writer.write_all(&out).and_then(|()| writer.flush()).map_err(Failure::Unknown)?; + match read_bounded_line(reader, MAX_REPLY_LINE).map_err(Failure::Unknown)? { + BoundedLine::Line(reply) => Ok(reply), + BoundedLine::Eof => Err(Failure::Unknown(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"))), + BoundedLine::TooLong => Err(Failure::Unknown(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long"))), + } +} + +/// Why a round trip failed. +enum Failure { + /// No write was attempted: the app did not run it. + NotSent(std::io::Error), + /// After the request was sent: the app may have run it. + Unknown(std::io::Error), +} + +impl Failure { + fn into_error(self) -> std::io::Error { + match self { + Self::NotSent(e) | Self::Unknown(e) => e, + } + } +} + +/// Methods that only read: sending them twice does no harm. +fn is_read_only(method: &str, params: &Value) -> bool { + match method { + "document.inspect" | "engine.commands" | "ui.inspect" | "ui.parity" => true, + "ui.render" | "ui.screenshot" => params.get("path").and_then(Value::as_str).is_none(), + _ => false, + } +} + impl Backend for Remote { fn call(&mut self, method: &str, params: Value) -> Result { let id = self.next_id; @@ -498,4 +552,100 @@ mod tests { assert!(e.contains("not the app's address") && e.contains("not sent"), "{e}"); assert!(seen.recv_timeout(WAIT).unwrap().get("key").is_none()); } + + #[test] + fn a_mutation_with_a_lost_reply_is_not_sent_again() { + // The app runs the request, then the connection drops without a reply. + let (port, seen) = fake_app(|_| None); + let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap(); + let e = r.call("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}})).unwrap_err(); + assert!(e.contains("outcome unknown"), "{e}"); + assert_eq!(seen.recv_timeout(WAIT).unwrap()["method"], "engine.execute"); + std::thread::sleep(Duration::from_millis(200)); + assert!(seen.try_recv().is_err(), "the mutation was sent a second time"); + } + + #[test] + fn a_read_with_a_lost_reply_is_sent_again() { + let n = Arc::new(AtomicUsize::new(0)); + let (port, seen) = + fake_app(move |_| if n.fetch_add(1, Ordering::SeqCst) == 0 { None } else { Some((json!({"ok": true, "result": 3}), false)) }); + let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap(); + assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(3)); + assert_eq!(seen.try_iter().count(), 2); + } + + #[test] + fn a_failed_write_after_delivering_json_never_replays_a_mutation() { + // Deliver a complete request, then fail either its newline write or its flush. The + // stand-in, like the production server, accepts a complete JSON body at EOF. + struct FailingWriter<'a> { + socket: &'a mut TcpStream, + fail_flush: bool, + wrote: bool, + } + impl Write for FailingWriter<'_> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.wrote { + return Err(std::io::ErrorKind::BrokenPipe.into()); + } + self.wrote = true; + let len = buf.len() - usize::from(!self.fail_flush); + self.socket.write_all(&buf[..len])?; + self.socket.shutdown(std::net::Shutdown::Write)?; + Ok(len) + } + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + } + for fail_flush in [false, true] { + let (port, seen) = fake_app(|_| None); + let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap(); + let mut attempts = 0; + let e = r + .exchange_with(1, "engine.execute", &json!({"command": "text.insert", "params": {"text": "once"}}), |r, line| { + attempts += 1; + let (reader, socket) = r.conn.as_mut().unwrap(); + roundtrip_io(reader, &mut FailingWriter { socket, fail_flush, wrote: false }, line) + }) + .unwrap_err(); + assert!(e.contains("outcome unknown"), "{e}"); + assert_eq!(attempts, 1, "a failed write must not trigger another exchange"); + assert_eq!(seen.recv_timeout(WAIT).unwrap()["params"]["params"]["text"], "once"); + assert!(seen.try_recv().is_err()); + } + } + + #[test] + fn image_exports_with_a_lost_reply_are_not_replayed_but_in_memory_reads_are() { + for method in ["ui.render", "ui.screenshot"] { + for path in [json!("out.png"), json!(""), Value::Null] { + let count = Arc::new(AtomicUsize::new(0)); + let n = count.clone(); + let (port, seen) = + fake_app(move |_| if n.fetch_add(1, Ordering::SeqCst) == 0 { None } else { Some((json!({"ok": true, "result": 3}), false)) }); + let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap(); + let result = r.call(method, json!({"path": path})); + if path.is_string() { + assert!(result.unwrap_err().contains("outcome unknown")); + assert_eq!(count.load(Ordering::SeqCst), 1); + } else { + assert_eq!(result.unwrap(), json!(3)); + assert_eq!(count.load(Ordering::SeqCst), 2); + } + assert_eq!(seen.try_iter().count(), if path.is_string() { 1 } else { 2 }); + } + assert!(is_read_only(method, &json!({})), "no path means PNG in the reply"); + } + } + + #[test] + fn a_mutation_not_sent_goes_out_on_a_new_connection() { + let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 1}), false))); + let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k")).unwrap(); + r.conn = None; + assert_eq!(r.call("engine.execute", json!({"command": "file.new"})).unwrap(), json!(1)); + assert_eq!(seen.try_iter().count(), 1); + } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index b3eaf5b49a..bd9616222d 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -106,10 +106,14 @@ impl Server { /// the protocol stream). pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { loop { - let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE)? { - BoundedLine::Eof => break, - BoundedLine::Line(line) => self.handle_line(&line), - BoundedLine::TooLong => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()), + let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE) { + Ok(BoundedLine::Eof) => break, + Ok(BoundedLine::Line(line)) => self.handle_line(&line), + Ok(BoundedLine::TooLong) => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()), + Err(e) if e.kind() == std::io::ErrorKind::InvalidData => { + Some(error(Value::Null, PARSE_ERROR, "request line is not UTF-8").to_string()) + } + Err(e) => return Err(e), }; if let Some(reply) = reply { output.write_all(reply.as_bytes())?; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 4e7c558ec1..f554d4bb84 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -431,3 +431,24 @@ fn render_page_rejects_a_page_number_that_does_not_fit() { let r = call(&mut s, 31, "tools/call", json!({"name": "render_page", "arguments": {"page": 1, "scale": 0.5}})); assert_eq!(r["result"]["isError"], false, "page 1 still renders: {r}"); } + +#[test] +fn invalid_utf8_does_not_change_arguments_or_stop_later_requests() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + let mut input = br#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"type_text","arguments":{"text":"bad"}}}"#.to_vec(); + let pos = input.windows(3).position(|w| w == b"bad").unwrap(); + input[pos] = 0xff; + input.push(b'\n'); + input.extend_from_slice( + b"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"type_text\",\"arguments\":{\"text\":\"good\"}}}\n", + ); + let mut out = Vec::new(); + s.serve(std::io::Cursor::new(input), &mut out).unwrap(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 2); + assert_eq!(replies[0]["error"]["code"], -32700); + assert_eq!(replies[1]["id"], 2); + assert_eq!(replies[1]["result"]["isError"], false); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"], "good", "malformed text must never reach the backend"); +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index e1058687b9..b1ff527c40 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -39,6 +39,11 @@ impl WordApp { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { let reply = req.reply.clone(); + if !req.start() { + // The caller timed out while this waited in the queue and was told it did not run. + log::debug!("control request {} dropped: its caller gave up", req.method); + continue; + } match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { let _ = reply.send(v); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 70e76217da..bb20e3865e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -43,12 +43,45 @@ pub struct ControlRequest { pub method: String, pub params: Value, pub reply: Sender, + /// Shared with the waiter: whichever of "start" and "give up" happens first wins. + state: std::sync::Arc, +} + +const QUEUED: u8 = 0; +const STARTED: u8 = 1; +const CANCELLED: u8 = 2; + +/// The waiter's side of a [`ControlRequest`]: it gives up on a request that has not started. +#[derive(Clone)] +pub struct ControlWaiter(std::sync::Arc); + +impl ControlWaiter { + /// Cancel the request. True when it had not started: it will never run. False when the UI + /// already started it: its outcome is unknown to the waiter. + pub fn cancel(&self) -> bool { + use std::sync::atomic::Ordering::SeqCst; + self.0.compare_exchange(QUEUED, CANCELLED, SeqCst, SeqCst).is_ok() + } } impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { + let (req, rx, _) = Self::with_waiter(method, params); + (req, rx) + } + + /// Like [`ControlRequest::new`], plus the handle the waiter uses to cancel a queued request. + pub fn with_waiter(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver, ControlWaiter) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx }, rx) + let state = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(QUEUED)); + (Self { method: method.into(), params, reply: tx, state: state.clone() }, rx, ControlWaiter(state)) + } + + /// The UI is about to run the request. False when the waiter already gave up on it: it + /// must not run. + pub fn start(&self) -> bool { + use std::sync::atomic::Ordering::SeqCst; + self.state.compare_exchange(QUEUED, STARTED, SeqCst, SeqCst).is_ok() } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0025f465ad..b12d4c5a5d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,5 +1,7 @@ # Control protocol +> **Last updated:** 2026-10-31 · #2596: accept-thread startup failure disables the server and removes its key file; MCP retry policy distinguishes file exports from in-memory images. + Start the app with a control port: `wordcraft --control 7981` (or `WORDCRAFT_CONTROL_PORT=7981`). A missing or invalid port after `--control` (not 1–65535) prints an error and exits with status 2. It listens on `127.0.0.1` only. Send one JSON object per line, with the window's key (see @@ -50,6 +52,21 @@ any other host it sends no key, says so, and the app refuses its requests: to dr another machine, forward its port to `127.0.0.1` (for example `ssh -L 7981:127.0.0.1:7981 host`) and set `WORDCRAFT_CONTROL_KEY` to that app's key. +## Delivery and startup failures + +If the accept thread cannot start, startup reports that the control server is off and removes +its newly written key file; it does not report a listening server. Failure to start a worker +for one connection closes only that connection and releases its admission slot. + +The server can accept a complete JSON body at EOF even without a final newline. A client that +encounters a write error must therefore treat delivery as unknown once it has attempted to +send. The MCP bridge does not replay mutations in this case, or image exports with a string +`path`; see [delivery failures](mcp.md#delivery-failures). + +A request that times out while still queued is cancelled and cannot run later. If execution +has already started, the timeout reports an unknown outcome rather than promising cancellation. +Unauthenticated connections are limited to 64 while they wait for their first authorised request. + ## View › Window The app's native windows (one per document, or several on one document) take part in View › diff --git a/docs/mcp.md b/docs/mcp.md index 2d1c184466..26e728fc6f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,7 @@ # MCP server +> **Last updated:** 2026-10-31 · Delivery review fixes for #2596: unknown outcomes are not replayed, including image exports to a path; invalid UTF-8 returns a parse error without stopping later requests. + WordCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so Claude and other agents can write and edit documents. @@ -18,6 +20,22 @@ isn't open yet: tool calls return "not reachable … will retry on each call" an app's port opens (#4078), so a client that starts before WordCraft doesn't lose the server for its whole session. +## Delivery failures + +The connected bridge retries a request once if no write was attempted (for example, a failed +connection). Once sending starts, a write or flush failure can mean the app already received +the complete JSON body, even if its newline was not delivered. A lost reply has the same +uncertainty. Mutations then return an `outcome unknown` error and are never automatically +resent; inspect the document before deciding whether to repeat the operation yourself. + +Read-only inspection and in-memory `ui.render` / `ui.screenshot` calls may retry once. With a +string `path`, rendering or taking a screenshot writes a file and is not retried after an +unknown outcome. Unreadable replies are reported as errors without replaying the request. + +On stdio, an invalid UTF-8 request line returns JSON-RPC parse error `-32700`. It never reaches +the backend, and the server continues with the next line instead of substituting characters +in text or paths. + ## Scripts from the command line `wordcraft-cli run --script job.json` (`--script -` reads standard input) runs the commands in a diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000000..4b166bfaa4 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,28 @@ +# Layout test fonts + +Layout unit tests use only the 13 font files already pinned under `assets/fonts/`: Source Sans 3, +Source Serif 4, Inter and JetBrains Mono, with their bundled styles. Family listings also include +legacy style names such as Inter Medium. They do not load installed fonts, user font preferences, +or the optional `CRAFT_FONTS_DIR` input. No download or extra font checkout is required. + +Every layout unit test starts with `crate::test_fonts();`, before constructing its fixture or +resolving a font. This initializes the process-wide database once, including when a single test is +run by name. The `wordcraft-fonts/test-support` development feature exposes the initializer; +enabling the feature alone has no effect. Initialization fails if an application database already +exists, rather than replacing a database whose faces may be in the shaping or math caches. +Other test binaries and application builds keep their normal font discovery behavior. + +Uninstalled requested families use the normal substitution lists, restricted to this fixed set. +In particular, the default Aptos request resolves to Source Sans 3. These fonts are not Calibri +metric substitutes: Word measurements remain reference behavior, while fixture dimensions must +account for the pinned font's metrics. Do not recalibrate a fixture from the same layout behavior +it is meant to test. When changing a pagination fixture, check that undoing its original layout +fix still makes the regression test fail. + +The fixed set does not cover every script or include an OpenType math font. Tests with an +existing unavailable-script guard still take that guard. Additional script or math font fixtures +belong in `storytold/craft-fonts`, with a reproducible test input before enabling them here. + +Run the suite with `cargo test -p wordcraft-layout`. The isolated font initialization regression +runs with `cargo test -p wordcraft-fonts --features test-support --test fixed_test_fonts`. +`cargo xtask ci` also runs it through the layout crate's development dependency.