mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
Merge remote-tracking branch 'upstream/main' into altgr-keytips
# Conflicts: # docs/ui-parity.md
This commit is contained in:
Generated
+1
@@ -5382,6 +5382,7 @@ dependencies = [
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
|
||||
+28
-2
@@ -2,7 +2,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-10-18 · **Change:** 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -104,7 +104,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
|
||||
| Spanish | es | partial | ~95% |
|
||||
| Hindi | hi | none | 0% |
|
||||
| Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% |
|
||||
| French | fr | partial (interface) | ~95% |
|
||||
| French | fr | partial (interface complete, native-speaker reviewed; no proofing) | 100% (2,615 entries) |
|
||||
| Portuguese | pt-br (pt-PT none) | partial | ~95% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | partial | ~95% |
|
||||
@@ -134,15 +134,19 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document.
|
||||
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip |
|
||||
| 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved |
|
||||
| 2026-10-15 | Table rows with an at-least height start the next page whole when less than their minimum is left on a page that already holds something, so one-row-per-page booklets keep their pages; rows taller than a page split from the top of the next page and repeated header rows go with the row (#1742) |
|
||||
| 2026-10-16 | Strict OOXML line heights render as Word renders them (#2329): `w:line="13.90pt"` under `lineRule="auto"` is a height read through twips (13.90 × 20 = 278; the 1.1583 multiple Word shows), not the 13.9 multiple the bare transitional number counts, so a Word 2024 file with 13.9 lines of line spacing opens at Word's 1 page with matching baselines instead of 10 |
|
||||
| 2026-10-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt |
|
||||
| 2026-10-14 | A bulleted or numbered list item that starts with a lower-case word no longer gets the grammar squiggle for a missing sentence capital (#1741): list items are often fragments, so the sentence-capital rule (every proofed language) skips the first word of a list paragraph (own or style numbering); later sentences in the item are still checked |
|
||||
| 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) |
|
||||
| 2026-10-16 | Update Field is offered on a table of contents after a save and reopen, and repeated save and update adds no blank line (#2182; the flow in that report already worked since #1197) |
|
||||
| 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. |
|
||||
| 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. |
|
||||
| 2026-10-16 | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
@@ -155,7 +159,17 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
|
||||
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
|
||||
| 2026-10-15 | Equations: typing `V_dd` or `x^ab` and pressing Space keeps `dd` and `ab` whole in the subscript or superscript, like `V_(dd)`, as Word for Mac does; Linear format shows the bare form |
|
||||
| 2026-10-15 | Plain text pasted from another program keeps the formatting of the paragraph it lands in: Paste inserts it as Keep Text Only does, so every pasted line, empty lines too, becomes a paragraph with that paragraph's direction, style, alignment, indents and list, and the text after the caret keeps them. The lines after the first had default formatting, so pasting several lines into a right-to-left paragraph turned all but the first left-to-right |
|
||||
| 2026-10-14 | Add Font… imports every file selected in the file chooser (#1789), not just the first: the desktop dialog and the web picker are multi-select, each file is added, a bad file is reported ("Added 11 fonts; 1 couldn't be read: X.ttf") without stopping the rest, and one pick adds at most 2,000 (the status line counts the skipped). On the web each font is added and reported on its own, with no single summary line. The built-in file browser still picks one file. |
|
||||
| 2026-10-15 | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
| 2026-10-15 | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
|
||||
| 2026-10-16 | Zero-extent drawing anchors contribute no inline font height. |
|
||||
| 2026-10-16 | Control-channel keyboard and text input reaches the focused widget while the native window is inactive, without activating it; normal widget targets, dialogs and document protection remain in force (#2420) |
|
||||
|---|---|
|
||||
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
|
||||
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
|
||||
| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. |
|
||||
| 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading |
|
||||
@@ -176,6 +190,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-17 | DOCX glossary documents (building blocks) open into the model: Insert › Quick Parts and AutoText list and insert the document's own entries, and saving an entry with `"in": "document"` keeps it in the file (undoable). An untouched glossary is saved back byte for byte; an edited one is written from the model |
|
||||
| 2026-10-16 | Indonesian proofing (#498): id-ID spelling reads the installed Hunspell `id_ID` dictionary (`hunspell-id` on Debian/Ubuntu, LibreOffice's `dict-id`, or the Dictionaries folder), suggestions use its MAP/REP/TRY/KEY tables, generic repeated-word (offering `anak-anak`) and spacing checks explain in Indonesian; Review › Language, Set Proofing Language and File › Options › Proofing offer Indonesian. No dictionary data bundled; hyphenation keeps the English fallback; parity estimates unchanged |
|
||||
| 2026-10-17 | A table inserted in a right-to-left paragraph is a right-to-left table, like Word, so Tab moves right to left through its cells (#2634) |
|
||||
| 2026-10-16 | French interface catalog completed: 402 entries added (equation gallery, built-in style and table-style names, citation styles, paper sizes, watermarks, Bookmark / Go To / Print dialogs), 2,615 in all, with a native-speaker review of the terminology; no French proofing yet |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-15 | Layout › Columns with text selected puts only that text in columns, as in Word (#2024): a continuous section break goes where the selection starts and one where it ends (splitting a paragraph there; none at the document's start or end, or where a section already begins or ends), and the columns go on the section between, one undo step; More Columns… offers "Selected text" and picks it. Before, the whole section took the columns |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
@@ -189,6 +204,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
|
||||
| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
|
||||
| 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
|
||||
| 2026-10-16 | Opening a DOCX no longer copies its pictures: each picture, and each embedded object or macro part kept for saving, was copied out of the unzipped package, so a document with pictures held them twice while it opened and left the package's copies behind as freed memory. The document now shares the package's bytes: a 92 MB document with 20 pictures opens in about 90 MB less (866 → 772 MB in the app on macOS; the command line's peak, 319 → 231 MB) |
|
||||
| 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. |
|
||||
| 2026-10-12 | Backspace at the start of the paragraph after a table nested in a cell moves into the nested table's last cell, no longer into the last cell of the table around it |
|
||||
| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word |
|
||||
@@ -456,6 +472,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | Progress log: a list item's first word is not flagged for a missing capital (#1741) |
|
||||
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
|
||||
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
|
||||
| 2026-10-16 | trivial | Save Changes exposes an alert-dialog role, the document-specific question and loss/failure context to accessibility; full-app focus/ancestry regression. Native screen-reader verification remains pending. |
|
||||
| 2026-10-16 | trivial | Progress log: the Spike |
|
||||
| 2026-10-16 | trivial | Progress log: `w:eastAsianLayout` kept on save |
|
||||
| 2026-10-15 | trivial | Progress log: undrawable private-use list bullets show the level's default bullet (#1683) |
|
||||
@@ -496,10 +515,17 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
|
||||
| 2026-10-14 | trivial | Progress log: RTF export writes sections (gap 19) |
|
||||
| 2026-10-15 | trivial | Progress log: multi-letter equation scripts (#1609) |
|
||||
| 2026-10-15 | trivial | Progress log: plain-text paste keeps the paragraph formatting where it lands |
|
||||
| 2026-10-14 | trivial | Progress log: Add Font imports every selected font file (#1789) |
|
||||
| 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
|
||||
| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
|
||||
| 2026-10-16 | trivial | Zero-extent drawing anchors contribute no inline font height. Synthetic regressions. |
|
||||
| 2026-10-16 | trivial | Control keyboard/text input works in the inactive native window (#2420) |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
| 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) |
|
||||
| 2026-10-16 | trivial | Progress log: opening a DOCX no longer copies its pictures |
|
||||
| 2026-10-12 | minor | Progress log: Word default shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations), addressing gap #24 |
|
||||
| 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles |
|
||||
| 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save |
|
||||
|
||||
@@ -224,13 +224,23 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Asyn
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("Documents", DOC_EXTS)
|
||||
};
|
||||
let many = purpose == "font";
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
// Add Font takes every selected file (#1789); the others one.
|
||||
let files = if many { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() };
|
||||
for file in files.into_iter().take(MAX_PICKED_FILES) {
|
||||
let mut bytes = file.read().await;
|
||||
let mut pending = inbox.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Fonts waiting for the app to drain them stay under a byte cap. A file
|
||||
// over it is queued empty, so Add Font reports that file on its own.
|
||||
let held: usize = pending.iter().map(|(_, b)| b.len()).fold(0, usize::saturating_add);
|
||||
if many && held.saturating_add(bytes.len()) > MAX_PENDING_PICKED_BYTES {
|
||||
bytes = Vec::new();
|
||||
}
|
||||
pending.push((file.file_name(), bytes));
|
||||
drop(pending);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
})),
|
||||
// Exports ask for a name; the browser decides where the download goes.
|
||||
@@ -250,6 +260,10 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Asyn
|
||||
}
|
||||
}
|
||||
|
||||
/// Most files one multi-select pick (Add Font) reads.
|
||||
const MAX_PICKED_FILES: usize = 2000;
|
||||
/// Most bytes of picked fonts held in the inbox before the app drains them.
|
||||
const MAX_PENDING_PICKED_BYTES: usize = 256 << 20;
|
||||
/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands).
|
||||
const MAX_SYSTEM_FONTS: usize = 500;
|
||||
/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small).
|
||||
|
||||
@@ -27,7 +27,7 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
|
||||
|
||||
/// A picker's answer, waited for off the UI thread.
|
||||
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
|
||||
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
|
||||
|
||||
/// Shows the dialogs the app queued; lives in the eframe app, which has the window.
|
||||
pub struct Launcher {
|
||||
@@ -56,13 +56,18 @@ impl Launcher {
|
||||
// Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
|
||||
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
|
||||
let picking: Picking = match req {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
|
||||
FileDialogRequest::Open { purpose } => {
|
||||
Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() })
|
||||
}
|
||||
FileDialogRequest::OpenMany { purpose } => {
|
||||
Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() })
|
||||
}
|
||||
FileDialogRequest::Save { name } => {
|
||||
let mut dialog = dialog.set_file_name(name.clone());
|
||||
for (label, exts) in save_filters(&name) {
|
||||
dialog = dialog.add_filter(label, &exts);
|
||||
}
|
||||
Box::pin(dialog.save_file())
|
||||
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
|
||||
}
|
||||
};
|
||||
let ctx = self.ctx.clone();
|
||||
@@ -70,7 +75,7 @@ impl Launcher {
|
||||
// The picker only starts when first polled: without a system dialog it never does.
|
||||
let picked = match no_system_dialog() {
|
||||
Some(reason) => Err(reason),
|
||||
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
|
||||
None => Ok(pollster::block_on(picking).iter().map(|f| f.path().to_string_lossy().to_string()).collect()),
|
||||
};
|
||||
let _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
|
||||
@@ -146,3 +146,7 @@ sync_github_name = true
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rem5411]
|
||||
real_name = "Vivien Janny"
|
||||
sync_github_name = true
|
||||
|
||||
+91
-2
@@ -155,6 +155,46 @@ impl Underline {
|
||||
}
|
||||
}
|
||||
|
||||
/// The OpenType ligatures a run asks for ([MS-DOCX] `w14:ligatures`): standard (`liga`),
|
||||
/// contextual (`clig`), historical (`hlig`) and discretional (`dlig`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Ligatures {
|
||||
pub standard: bool,
|
||||
pub contextual: bool,
|
||||
pub historical: bool,
|
||||
pub discretional: bool,
|
||||
}
|
||||
|
||||
impl Ligatures {
|
||||
/// Every combination of the four flags.
|
||||
pub fn all() -> impl Iterator<Item = Ligatures> {
|
||||
(0u8..16).map(|b| Ligatures { standard: b & 1 != 0, contextual: b & 2 != 0, historical: b & 4 != 0, discretional: b & 8 != 0 })
|
||||
}
|
||||
/// The `w14:ligatures/@w14:val` name: `none`, `all`, or the set flags joined in camelCase
|
||||
/// (`standardContextual`).
|
||||
pub fn ooxml(self) -> String {
|
||||
let on: Vec<&str> =
|
||||
[(self.standard, "standard"), (self.contextual, "contextual"), (self.historical, "historical"), (self.discretional, "discretional")]
|
||||
.into_iter()
|
||||
.filter_map(|(on, n)| on.then_some(n))
|
||||
.collect();
|
||||
match on.len() {
|
||||
0 => "none".into(),
|
||||
4 => "all".into(),
|
||||
_ => on
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, n)| n.chars().enumerate().map(move |(j, c)| if i > 0 && j == 0 { c.to_ascii_uppercase() } else { c }))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// `None` for a name [MS-DOCX] doesn't define.
|
||||
pub fn from_ooxml(s: &str) -> Option<Ligatures> {
|
||||
Ligatures::all().find(|l| l.ooxml() == s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VertAlign {
|
||||
@@ -331,6 +371,10 @@ pub struct CharProps {
|
||||
/// Kerning for fonts at or above this size (points); 0 = off.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kern: Option<f32>,
|
||||
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
|
||||
/// contextual ligatures.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ligatures: Option<Ligatures>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -500,7 +544,7 @@ impl CharProps {
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
drop_same_fields!(self, base; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del);
|
||||
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -511,7 +555,7 @@ impl CharProps {
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change);
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
@@ -1191,6 +1235,9 @@ pub struct CellProps {
|
||||
pub valign: Option<VAlign>,
|
||||
/// Cell margins override (top, left, bottom, right), points.
|
||||
pub margins: Option<[f32; 4]>,
|
||||
/// Per-side overrides when `margins` is absent; omitted sides inherit table defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub partial_margins: Option<[Option<f32>; 4]>,
|
||||
/// Which way the cell's text runs.
|
||||
pub text_direction: TextDirection,
|
||||
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
|
||||
@@ -1206,6 +1253,17 @@ pub struct CellProps {
|
||||
pub fmt_change: Option<Box<PropChange<CellProps>>>,
|
||||
}
|
||||
|
||||
impl CellProps {
|
||||
/// Resolve top, left, bottom and right margins against the current table defaults.
|
||||
/// A complete override takes precedence over per-side overrides.
|
||||
pub fn resolved_margins(&self, defaults: [f32; 4]) -> [f32; 4] {
|
||||
if let Some(margins) = self.margins {
|
||||
return margins;
|
||||
}
|
||||
self.partial_margins.map_or(defaults, |sides| std::array::from_fn(|i| sides[i].unwrap_or(defaults[i])))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1254,6 +1312,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 16 `w14:ligatures` values of [MS-DOCX], each to its flags and back.
|
||||
#[test]
|
||||
fn ligature_names() {
|
||||
let l = |standard, contextual, historical, discretional| Ligatures { standard, contextual, historical, discretional };
|
||||
for (name, flags) in [
|
||||
("none", l(false, false, false, false)),
|
||||
("standard", l(true, false, false, false)),
|
||||
("contextual", l(false, true, false, false)),
|
||||
("historical", l(false, false, true, false)),
|
||||
("discretional", l(false, false, false, true)),
|
||||
("standardContextual", l(true, true, false, false)),
|
||||
("standardHistorical", l(true, false, true, false)),
|
||||
("contextualHistorical", l(false, true, true, false)),
|
||||
("standardDiscretional", l(true, false, false, true)),
|
||||
("contextualDiscretional", l(false, true, false, true)),
|
||||
("historicalDiscretional", l(false, false, true, true)),
|
||||
("standardContextualHistorical", l(true, true, true, false)),
|
||||
("standardContextualDiscretional", l(true, true, false, true)),
|
||||
("standardHistoricalDiscretional", l(true, false, true, true)),
|
||||
("contextualHistoricalDiscretional", l(false, true, true, true)),
|
||||
("all", l(true, true, true, true)),
|
||||
] {
|
||||
assert_eq!(Ligatures::from_ooxml(name), Some(flags), "{name}");
|
||||
assert_eq!(flags.ooxml(), name);
|
||||
}
|
||||
assert_eq!(Ligatures::all().count(), 16);
|
||||
for bad in ["", "Standard", "contextualStandard", "standardstandard", "allx"] {
|
||||
assert_eq!(Ligatures::from_ooxml(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_direction_names() {
|
||||
let mut d = TextDirection::Horizontal;
|
||||
|
||||
@@ -39,6 +39,10 @@ pub struct ResolvedChar {
|
||||
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
|
||||
#[serde(default)]
|
||||
pub kern: f32,
|
||||
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
|
||||
/// contextual).
|
||||
#[serde(default)]
|
||||
pub ligatures: Option<crate::props::Ligatures>,
|
||||
pub outline: bool,
|
||||
pub shadow: bool,
|
||||
pub emboss: bool,
|
||||
@@ -90,6 +94,7 @@ impl ResolvedChar {
|
||||
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
|
||||
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
|
||||
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
|
||||
ligatures: c.ligatures,
|
||||
outline: c.outline.unwrap_or(false),
|
||||
shadow: c.shadow.unwrap_or(false),
|
||||
emboss: c.emboss.unwrap_or(false),
|
||||
|
||||
@@ -14,10 +14,20 @@ const MAX_FONTS: usize = 64;
|
||||
/// The font table's references to embedded font files, one per style.
|
||||
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
|
||||
|
||||
/// A font file a document embeds, and the name its font table gives the font: the name the text
|
||||
/// asks for, which a subset Word embeds doesn't carry inside (#2240).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EmbeddedFont {
|
||||
/// `w:font/@w:name`.
|
||||
pub name: String,
|
||||
/// The font file, de-obfuscated.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The font files `doc`'s font table embeds, de-obfuscated, in table order. Not checked to be
|
||||
/// fonts: that is up to the font database. Empty for a document read from anything but a package
|
||||
/// with a font table.
|
||||
pub fn embedded_fonts(doc: &Document) -> Vec<Vec<u8>> {
|
||||
pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -26,7 +36,10 @@ pub fn embedded_fonts(doc: &Document) -> Vec<Vec<u8>> {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for embed in table.children("w:font").flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str()))) {
|
||||
let embeds = table
|
||||
.children("w:font")
|
||||
.flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e)));
|
||||
for (name, embed) in embeds {
|
||||
if out.len() >= MAX_FONTS {
|
||||
log::warn!("docx: more than {MAX_FONTS} embedded fonts; the rest are not loaded");
|
||||
break;
|
||||
@@ -52,7 +65,7 @@ pub fn embedded_fonts(doc: &Document) -> Vec<Vec<u8>> {
|
||||
for (i, b) in data.iter_mut().take(32).enumerate() {
|
||||
*b ^= key.get(i % 16).copied().unwrap_or(0);
|
||||
}
|
||||
out.push(data);
|
||||
out.push(EmbeddedFont { name: name.to_string(), data });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub use crypt::{
|
||||
is_encrypted, protection_hash,
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::embedded_fonts;
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
pub use read::read;
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use wordml2003_write::write as write_wordml2003;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::DocxError;
|
||||
use crate::xml::{self, El};
|
||||
@@ -186,9 +187,10 @@ pub fn rel_is(t: &str, full: &str) -> bool {
|
||||
t == full || tail(t) == tail(full)
|
||||
}
|
||||
|
||||
/// The decompressed parts of a package (bounded).
|
||||
/// The decompressed parts of a package (bounded). Shared, so a part the document keeps as it is (a
|
||||
/// picture, an embedded object) is handed over rather than copied.
|
||||
pub struct Package {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -230,7 +232,7 @@ impl Package {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
files.insert(name, buf);
|
||||
files.insert(name, Arc::new(buf));
|
||||
}
|
||||
Ok(Package { files })
|
||||
}
|
||||
@@ -238,16 +240,22 @@ impl Package {
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
Package { files }
|
||||
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
|
||||
}
|
||||
|
||||
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
|
||||
pub fn get(&self, name: &str) -> Option<&[u8]> {
|
||||
self.get_shared(name).map(|v| v.as_slice())
|
||||
}
|
||||
|
||||
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
|
||||
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
|
||||
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
|
||||
let n = name.trim_start_matches('/');
|
||||
if let Some(v) = self.files.get(n) {
|
||||
return Some(v);
|
||||
}
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v.as_slice())
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
|
||||
@@ -432,6 +440,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_shares_its_pictures_with_the_package() {
|
||||
// Each picture was copied out of the package, so a picture document held its pictures
|
||||
// twice while it opened, and left the package's copies behind as freed memory.
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body><w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/pic.png"/></Relationships>"#;
|
||||
let z = zip_of(&[
|
||||
("word/document.xml", document.into_bytes()),
|
||||
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
|
||||
("word/media/pic.png", b"\x89PNG picture bytes".to_vec()),
|
||||
]);
|
||||
let pkg = Package::open(&z).unwrap();
|
||||
let doc = crate::read::read_opened(&pkg).unwrap();
|
||||
let held = doc.media.values().next().expect("the picture is read");
|
||||
assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy");
|
||||
assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_targets() {
|
||||
assert_eq!(resolve("word", "media/image1.png"), "word/media/image1.png");
|
||||
|
||||
@@ -94,7 +94,8 @@ impl Reader<'_> {
|
||||
/// relationship the main document's markup refers to by id (its target goes with the
|
||||
/// element, or not at all).
|
||||
pub(super) fn keep_unmodelled(&mut self, package: &Rels, document: &Rels) {
|
||||
let main = self.pkg.get(&self.main).map(|b| b.to_vec()).unwrap_or_default();
|
||||
let pkg = self.pkg;
|
||||
let main = pkg.get(&self.main).unwrap_or_default();
|
||||
let referenced = |id: &str| {
|
||||
let needle = format!("\"{id}\"");
|
||||
main.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
@@ -195,14 +196,14 @@ impl Reader<'_> {
|
||||
log::warn!("docx: more than {MAX_EMBEDDED_PARTS} parts for embedded objects; the rest are dropped");
|
||||
return false;
|
||||
}
|
||||
let Some(bytes) = self.pkg.get(part) else {
|
||||
let Some(bytes) = self.pkg.get_shared(part) else {
|
||||
log::warn!("docx: part {part} of an embedded object is missing");
|
||||
return false;
|
||||
};
|
||||
let pkg = self.pkg;
|
||||
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(part).unwrap_or("application/octet-stream");
|
||||
let content_type = if manifest_field_ok(ct) { ct.to_string() } else { "application/octet-stream".to_string() };
|
||||
self.doc.passthrough.entry(part.to_string()).or_insert_with(|| Arc::new(bytes.to_vec()));
|
||||
self.doc.passthrough.entry(part.to_string()).or_insert_with(|| bytes.clone());
|
||||
self.embedded.parts.insert(part.to_string(), crate::package::EmbeddedPart { content_type, rels: Vec::new() });
|
||||
true
|
||||
}
|
||||
|
||||
@@ -282,9 +282,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
// bytes, never parsed or run, so a .docm/.dotm saved again carries exactly what the file had.
|
||||
// Only the relationships and content types are read.
|
||||
if let Some(v) = part(rt::VBA_PROJECT)
|
||||
&& let Some(b) = pkg.get(&v).filter(|b| !b.is_empty())
|
||||
&& let Some(b) = pkg.get_shared(&v).filter(|b| !b.is_empty())
|
||||
{
|
||||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
|
||||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), b.clone());
|
||||
r.read_vba_related(&v);
|
||||
}
|
||||
r.read_glossary(&rels);
|
||||
@@ -579,7 +579,7 @@ impl Reader<'_> {
|
||||
log::warn!("docx: not carrying VBA-related part {path:?}");
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = self.pkg.get(path) else {
|
||||
let Some(bytes) = self.pkg.get_shared(path) else {
|
||||
log::warn!("docx: VBA-related part {path} is missing; keeping the project without it");
|
||||
continue;
|
||||
};
|
||||
@@ -589,7 +589,7 @@ impl Reader<'_> {
|
||||
}
|
||||
kept += 1;
|
||||
manifest.push_str(&format!("{}\t{path}\t{ct}\n", rel.kind));
|
||||
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| Arc::new(bytes.to_vec()));
|
||||
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| bytes.clone());
|
||||
}
|
||||
if !manifest.is_empty() {
|
||||
self.doc.passthrough.insert(VBA_RELATED.into(), Arc::new(manifest.into_bytes()));
|
||||
@@ -644,7 +644,7 @@ impl Reader<'_> {
|
||||
if let Some(k) = self.media_by_path.get(&rel.target) {
|
||||
return Some(k.clone());
|
||||
}
|
||||
let bytes = self.pkg.get(&rel.target)?;
|
||||
let bytes = self.pkg.get_shared(&rel.target)?;
|
||||
let base: String =
|
||||
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
|
||||
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
|
||||
@@ -657,7 +657,7 @@ impl Reader<'_> {
|
||||
None => format!("{base}_{n}"),
|
||||
};
|
||||
}
|
||||
self.doc.media.insert(key.clone(), Arc::new(bytes.to_vec()));
|
||||
self.doc.media.insert(key.clone(), bytes.clone());
|
||||
self.media_by_path.insert(rel.target.clone(), key.clone());
|
||||
Some(key)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use wordcraft_doc::section::{
|
||||
};
|
||||
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, measure, on_off, tw, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -208,11 +208,8 @@ impl PropCtx {
|
||||
"w:szCs" => c.size_cs = k.attr("w:val").and_then(|v| measure(v, 2.0)).filter(|v| *v > 0.0).map(|v| v.clamp(1.0, 1638.0)),
|
||||
"w:highlight" => c.highlight = k.attr("w:val").map(Highlight::from_ooxml),
|
||||
"w:u" => {
|
||||
if let Some(v) = k.attr("w:val") {
|
||||
c.underline = Some(Underline::from_ooxml(v));
|
||||
} else {
|
||||
c.underline = Some(Underline::Single);
|
||||
}
|
||||
// w:val has no default in the schema: without it Word draws no underline
|
||||
c.underline = k.attr("w:val").map(Underline::from_ooxml);
|
||||
c.underline_color =
|
||||
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
|
||||
}
|
||||
@@ -233,6 +230,7 @@ impl PropCtx {
|
||||
c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
|
||||
}
|
||||
"w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml),
|
||||
n if n.starts_with("w14:") => {
|
||||
let fx = c.text_effects.get_or_insert_with(Default::default);
|
||||
super::wordart::read(k, &self.theme, fx);
|
||||
@@ -317,7 +315,8 @@ impl PropCtx {
|
||||
p.line_spacing = match rule.as_str() {
|
||||
"exact" => measure(line, 20.0).map(|v| LineSpacing::Exactly(v.abs())),
|
||||
"atleast" => measure(line, 20.0).map(|v| LineSpacing::AtLeast(v.abs())),
|
||||
_ => measure(line, 240.0).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
|
||||
// Strict OOXML writes the height with a unit (`13.90pt`, #2329).
|
||||
_ => line_multiple(line).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -431,7 +430,11 @@ pub fn borders(e: &El) -> Borders {
|
||||
|
||||
/// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right].
|
||||
pub fn margins(e: &El) -> [f32; 4] {
|
||||
let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).unwrap_or(0.0).clamp(0.0, 1584.0);
|
||||
margin_sides(e).map(|side| side.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn margin_sides(e: &El) -> [Option<f32>; 4] {
|
||||
let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).map(|v| v.clamp(0.0, 1584.0));
|
||||
[get(&["w:top"]), get(&["w:left", "w:start"]), get(&["w:bottom"]), get(&["w:right", "w:end"])]
|
||||
}
|
||||
|
||||
@@ -566,7 +569,14 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
|
||||
"w:shd" => c.shading = pc.shd_fill(k),
|
||||
"w:noWrap" => c.no_wrap = on_off(k),
|
||||
"w:hideMark" => c.hide_mark = on_off(k),
|
||||
"w:tcMar" => c.margins = Some(margins(k)),
|
||||
"w:tcMar" => {
|
||||
let sides = margin_sides(k);
|
||||
if sides.iter().all(Option::is_some) {
|
||||
c.margins = Some(sides.map(|side| side.unwrap_or(0.0)));
|
||||
} else {
|
||||
c.partial_margins = Some(sides);
|
||||
}
|
||||
}
|
||||
"w:textDirection" => {
|
||||
c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
|
||||
c.text_direction_set = k.attr("w:val") == Some("lrTb");
|
||||
|
||||
@@ -997,7 +997,9 @@ impl Reader<'_> {
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
*e = (*e - p).clamp(0.0, 1584.0);
|
||||
if *e != 0.0 {
|
||||
*e = (*e - p).clamp(0.0, 1584.0);
|
||||
}
|
||||
}
|
||||
// The renderer's existing missing-picture box marks ink without a usable preview.
|
||||
// InkML stays opaque: no approximation of its strokes or conversion to freeforms.
|
||||
@@ -1053,7 +1055,10 @@ impl Reader<'_> {
|
||||
if let Some(f) = obj.float_mut() {
|
||||
let (px, py) = f.spin_pad(w, h);
|
||||
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
|
||||
*e = (*e - p).clamp(0.0, 1584.0);
|
||||
// Zero adds no effects room when rotation contracts this axis.
|
||||
if *e != 0.0 {
|
||||
*e = (*e - p).clamp(0.0, 1584.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(obj)
|
||||
|
||||
@@ -34,6 +34,17 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
|
||||
pt.is_finite().then_some(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT))
|
||||
}
|
||||
|
||||
/// `w:line` under `lineRule="auto"`: the multiple of single line spacing, in 240ths (240 =
|
||||
/// single). Without a unit the value already is that — the transitional form. A universal
|
||||
/// measure is a height (strict OOXML writes `w:line="13.90pt"`, ISO 29500
|
||||
/// `ST_SignedTwipsMeasure`), so it goes through twips as Word reads it: × 20 ÷ 240 (13.90 pt =
|
||||
/// 278 twips, the 1.1583 multiple Word renders).
|
||||
pub fn line_multiple(s: &str) -> Option<f32> {
|
||||
let t = s.trim();
|
||||
let measured = t.ends_with(|c: char| c.is_ascii_alphabetic());
|
||||
measure(t, 240.0).map(|v| if measured { v * 20.0 / 240.0 } else { v })
|
||||
}
|
||||
|
||||
/// Plain integer (accepts decimals, rounds; clamps into i64 range).
|
||||
pub fn int(s: &str) -> Option<i64> {
|
||||
let t = s.trim();
|
||||
@@ -98,6 +109,16 @@ mod tests {
|
||||
assert_eq!(measure("1e400", 20.0), None);
|
||||
assert_eq!(measure("99999999999999", 20.0), Some(MAX_LEN_PT));
|
||||
assert_eq!(measure("x", 20.0), None);
|
||||
// `w:line` auto: the multiple in 240ths without a unit, through twips with one (#2329).
|
||||
assert_eq!(line_multiple("240"), Some(1.0));
|
||||
assert_eq!(line_multiple("276"), Some(1.15));
|
||||
assert_eq!(line_multiple("0"), Some(0.0));
|
||||
let strict = line_multiple("13.90pt").expect("strict line height");
|
||||
assert!((strict - 278.0 / 240.0).abs() < 1e-5, "{strict}");
|
||||
let inches = line_multiple("1.2in").expect("inches");
|
||||
assert!((inches - 7.2).abs() < 1e-5, "{inches}");
|
||||
assert_eq!(line_multiple("junk"), None);
|
||||
assert_eq!(line_multiple(""), None);
|
||||
assert_eq!(int("12.6"), Some(13));
|
||||
assert_eq!(u32_of("-5"), Some(0));
|
||||
}
|
||||
|
||||
@@ -81,11 +81,12 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
if let Some(h) = c.highlight {
|
||||
w.val("w:highlight", if h == Highlight::None { "none" } else { h.ooxml() });
|
||||
}
|
||||
if let Some(u) = c.underline {
|
||||
match c.underline_color {
|
||||
Some(col) => w.empty("w:u", &[("w:val", u.ooxml()), ("w:color", &col.hex())]),
|
||||
None => w.val("w:u", u.ooxml()),
|
||||
}
|
||||
match (c.underline, c.underline_color) {
|
||||
(Some(u), Some(col)) => w.empty("w:u", &[("w:val", u.ooxml()), ("w:color", &col.hex())]),
|
||||
(Some(u), None) => w.val("w:u", u.ooxml()),
|
||||
// A colour-only w:u, as read: keep it without inventing an underline
|
||||
(None, Some(col)) => w.empty("w:u", &[("w:color", &col.hex())]),
|
||||
(None, None) => {}
|
||||
}
|
||||
if let Some(b) = &c.border {
|
||||
border_el(w, "w:bdr", b);
|
||||
@@ -118,6 +119,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
if let Some(fx) = &c.text_effects {
|
||||
super::wordart::rpr_effects(w, fx);
|
||||
}
|
||||
if let Some(l) = c.ligatures {
|
||||
w.empty("w14:ligatures", &[("w14:val", &l.ooxml())]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:eastAsianLayout` with the attributes that are set (ISO/IEC 29500-1 §17.3.2.10).
|
||||
@@ -397,9 +401,15 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
}
|
||||
|
||||
pub fn margins(w: &mut W, tag: &str, m: &[f32; 4]) {
|
||||
margin_sides(w, tag, &m.map(Some));
|
||||
}
|
||||
|
||||
fn margin_sides(w: &mut W, tag: &str, m: &[Option<f32>; 4]) {
|
||||
w.open(tag, &[]);
|
||||
for (name, v) in ["w:top", "w:left", "w:bottom", "w:right"].iter().zip(m.iter()) {
|
||||
w.empty(name, &[("w:w", &twips(v.clamp(0.0, 1584.0))), ("w:type", "dxa")]);
|
||||
for (name, value) in ["w:top", "w:left", "w:bottom", "w:right"].iter().zip(m.iter()) {
|
||||
if let Some(v) = value {
|
||||
w.empty(name, &[("w:w", &twips(v.clamp(0.0, 1584.0))), ("w:type", "dxa")]);
|
||||
}
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
@@ -603,6 +613,8 @@ fn tcpr_inner(w: &mut W, c: &CellProps) {
|
||||
}
|
||||
if let Some(m) = &c.margins {
|
||||
margins(w, "w:tcMar", m);
|
||||
} else if let Some(m) = &c.partial_margins {
|
||||
margin_sides(w, "w:tcMar", m);
|
||||
}
|
||||
if c.text_direction.is_turned() || c.text_direction_set {
|
||||
w.val("w:textDirection", c.text_direction.ooxml());
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Original partial-cell-margin fixture (ECMA-376 §17.4.69).
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let document = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:tbl><w:tblPr><w:tblCellMar><w:top w:w="55" w:type="dxa"/><w:left w:w="45" w:type="dxa"/><w:bottom w:w="55" w:type="dxa"/><w:right w:w="55" w:type="dxa"/></w:tblCellMar></w:tblPr><w:tblGrid><w:gridCol w:w="4320"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcMar><w:left w:w="0" w:type="dxa"/></w:tcMar></w:tcPr><w:p><w:r><w:t>Point</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>"#;
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
zip.start_file("word/document.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(document.as_bytes()).unwrap();
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_margin_stays_partial() {
|
||||
let doc = wordcraft_docx::read(&fixture()).unwrap();
|
||||
let table = doc.body[0].as_table().unwrap();
|
||||
assert_eq!(table.rows[0].cells[0].props.margins, None, "a left-only margin is not a complete override");
|
||||
assert_eq!(table.rows[0].cells[0].props.resolved_margins([6.0, 7.0, 8.0, 9.0]), [6.0, 0.0, 8.0, 9.0]);
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let reopened = wordcraft_docx::read(&bytes).unwrap();
|
||||
let cell = &reopened.body[0].as_table().unwrap().rows[0].cells[0];
|
||||
assert_eq!(cell.props.resolved_margins([6.0, 7.0, 8.0, 9.0]), [6.0, 0.0, 8.0, 9.0]);
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let margins = xml.split("<w:tcMar>").nth(1).unwrap().split("</w:tcMar>").next().unwrap();
|
||||
assert!(margins.contains("<w:left w:w=\"0\""));
|
||||
assert!(!margins.contains("<w:top"));
|
||||
assert!(!margins.contains("<w:bottom"));
|
||||
assert!(!margins.contains("<w:right"));
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, Underline, VMerge};
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
|
||||
@@ -882,6 +882,28 @@ fn line_rule_is_read_case_insensitively() {
|
||||
assert_eq!(p[1].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(15.0)));
|
||||
}
|
||||
|
||||
/// #2329: Word 2024 saves strict OOXML with the line height written out with a unit
|
||||
/// (`w:line="13.90pt"`). Under `auto` it reads through twips, as Word renders it (13.90 × 20 =
|
||||
/// 278; the 1.1583 multiple), not as 240ths of a line like the bare number — a Word file with
|
||||
/// a `line="13.90pt"` default came in at 13.9 lines per line of text.
|
||||
#[test]
|
||||
fn strict_line_heights_with_a_unit_are_read_as_point_multiples() {
|
||||
let d = read_body(
|
||||
r#"<w:p><w:pPr><w:spacing w:after="8pt" w:line="13.90pt" w:lineRule="auto"/></w:pPr><w:r><w:t>x</w:t></w:r></w:p><w:p><w:pPr><w:spacing w:line="13.90pt" w:lineRule="exact"/></w:pPr></w:p>"#,
|
||||
);
|
||||
let p = paras(&d);
|
||||
let Some(wordcraft_doc::props::LineSpacing::Multiple(m)) = p[0].props.line_spacing else {
|
||||
panic!("auto line spacing: {:?}", p[0].props.line_spacing)
|
||||
};
|
||||
assert!((m - 278.0 / 240.0).abs() < 1e-5, "{m}");
|
||||
assert_eq!(p[0].props.space_after, Some(8.0), "the unit form of w:after too");
|
||||
assert_eq!(p[1].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(13.9)), "exact keeps reading it as points");
|
||||
// Saves as the transitional 240ths form and reads back the same multiple.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let Some(wordcraft_doc::props::LineSpacing::Multiple(m2)) = paras(&again)[0].props.line_spacing else { panic!("multiple after a save") };
|
||||
assert!((m2 - m).abs() < 1e-5, "{m2} vs {m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_border_reads_and_nil_resolves_off() {
|
||||
let d = read_body(
|
||||
@@ -1666,6 +1688,23 @@ fn theme_colour_without_a_theme_keeps_the_fixed_colour() {
|
||||
assert_eq!(paras(&d)[0].props_of_char(0).color, Some(TextColor::Rgb(Rgb(0, 0xFF, 0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underline_without_a_val_draws_no_underline() {
|
||||
// w:val has no schema default (ECMA-376 CT_Underline); Word leaves a bare w:u with a colour plain.
|
||||
let d = read_body(
|
||||
r#"<w:p><w:r><w:rPr><w:u w:val="single"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:u w:color="FF0000"/></w:rPr><w:t>b</w:t></w:r><w:r><w:t>c</w:t></w:r></w:p>"#,
|
||||
);
|
||||
let p = paras(&d);
|
||||
assert_eq!(p[0].props_of_char(0).underline, Some(Underline::Single));
|
||||
let b = p[0].props_of_char(1);
|
||||
assert_eq!(b.underline, None);
|
||||
assert_eq!(b.underline_color, Some(Rgb(0xFF, 0, 0)), "the colour is still read");
|
||||
assert_eq!(p[0].props_of_char(2).underline, None);
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let b = paras(&back)[0].props_of_char(1);
|
||||
assert_eq!((b.underline, b.underline_color), (None, Some(Rgb(0xFF, 0, 0))), "saving keeps the colour, not an underline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_font_wins_over_the_fixed_font() {
|
||||
let styles = r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light" w:asciiTheme="majorHAnsi" w:hAnsiTheme="majorHAnsi"/></w:rPr></w:style>"#;
|
||||
@@ -2996,3 +3035,26 @@ fn bookmark_end_name_and_id_trim() {
|
||||
.collect();
|
||||
assert_eq!(names, vec![("start", "section_intro".into()), ("end", "section_intro".into()), ("end", "standalone_target".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_effects_stay_zero_rotated() {
|
||||
const ANGLE_UNITS_PER_DEGREE: i32 = 60_000;
|
||||
|
||||
// ECMA-376 §20.4.2.6: zero effect extents add no space around a rotated picture.
|
||||
for degrees in [0, 90, 180, 270] {
|
||||
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="457200"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="{}"><a:off x="0" y="0"/><a:ext cx="914400" cy="457200"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
|
||||
degrees * ANGLE_UNITS_PER_DEGREE
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
|
||||
let (w, h, float) = paras(&d)[0].objects[0].frame().unwrap();
|
||||
assert_eq!(float.rot, degrees as f32);
|
||||
assert_eq!(float.effect, [0.0; 4], "rotation {degrees} must not invent effects");
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
let expected = if degrees % 180 == 0 { (w, h) } else { (h, w) };
|
||||
assert!((w + 2.0 * px - expected.0).abs() < 0.001);
|
||||
assert!((h + 2.0 * py - expected.1).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! OpenType ligatures (`w14:ligatures`, [MS-DOCX]) in document defaults, styles and runs.
|
||||
//! Synthetic packages, written here; no external documents are needed.
|
||||
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::props::Ligatures;
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
|
||||
fn package(defaults: &str, style: &str, run: &str) -> Vec<u8> {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w14""#;
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body><w:p><w:r><w:rPr><w:rStyle w:val="Fancy"/>{run}</w:rPr><w:t>office affinity</w:t></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!(
|
||||
r#"<w:styles {ns}><w:docDefaults><w:rPrDefault><w:rPr>{defaults}</w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="character" w:styleId="Fancy"><w:name w:val="Fancy"/><w:rPr>{style}</w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, data) in [
|
||||
("_rels/.rels", root),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn paragraph(d: &Document) -> &Paragraph {
|
||||
d.body.iter().find_map(|b| b.as_para()).unwrap()
|
||||
}
|
||||
|
||||
fn lig(name: &str) -> Option<Ligatures> {
|
||||
Some(Ligatures::from_ooxml(name).unwrap())
|
||||
}
|
||||
|
||||
/// Word stores the Font dialog's Ligatures choice as `w14:ligatures`; each level keeps its own
|
||||
/// value through reading and saving, and an explicit `none` is not the same as no value.
|
||||
#[test]
|
||||
fn ligatures_in_defaults_styles_and_runs_survive_saving() {
|
||||
let el = |v: &str| format!(r#"<w14:ligatures w14:val="{v}"/>"#);
|
||||
let bytes = package(&el("standardContextual"), &el("none"), &el("historicalDiscretional"));
|
||||
let mut d = wordcraft_docx::read(&bytes).unwrap();
|
||||
for generation in 0..3 {
|
||||
assert_eq!(d.styles.default_chr.ligatures, lig("standardContextual"), "defaults, generation {generation}");
|
||||
assert_eq!(d.styles.get("Fancy").and_then(|s| s.chr.ligatures), lig("none"), "style, generation {generation}");
|
||||
let run = ¶graph(&d).runs[0].props;
|
||||
assert_eq!(run.ligatures, lig("historicalDiscretional"), "run, generation {generation}");
|
||||
assert_eq!(run.text_effects, None, "not read as a WordArt effect");
|
||||
d = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A value [MS-DOCX] doesn't define is ignored, the run keeps inheriting.
|
||||
#[test]
|
||||
fn unknown_ligature_values_are_ignored() {
|
||||
let d = wordcraft_docx::read(&package("", "", r#"<w14:ligatures w14:val="fancy"/>"#)).unwrap();
|
||||
assert_eq!(paragraph(&d).runs[0].props.ligatures, None);
|
||||
let d = wordcraft_docx::read(&package("", "", "<w14:ligatures/>")).unwrap();
|
||||
assert_eq!(paragraph(&d).runs[0].props.ligatures, None);
|
||||
}
|
||||
|
||||
/// Without a value nothing is written, so documents that never asked for ligatures stay as they
|
||||
/// were.
|
||||
#[test]
|
||||
fn no_ligatures_element_without_a_value() {
|
||||
let d = wordcraft_docx::read(&package("", "", "")).unwrap();
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out)).unwrap();
|
||||
for name in ["word/document.xml", "word/styles.xml"] {
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
|
||||
assert!(!xml.contains("ligatures"), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ fn every_char_prop_round_trips() {
|
||||
scale: Some(150.0),
|
||||
position: Some(3.0),
|
||||
kern: Some(8.0),
|
||||
ligatures: Some(wordcraft_doc::props::Ligatures { standard: true, historical: true, ..Default::default() }),
|
||||
outline: Some(true),
|
||||
shadow: Some(true),
|
||||
emboss: Some(false),
|
||||
|
||||
@@ -239,10 +239,13 @@ fn insert_spike(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Paste keeps the source formatting: our own copy while the system clipboard still holds it,
|
||||
/// else another program's HTML or RTF (#687), else the system clipboard's text.
|
||||
/// else another program's HTML or RTF (#687). The system clipboard's text has none, so it pastes
|
||||
/// as Keep Text Only does.
|
||||
fn paste(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let frag = super::paste::paste_fragment(s, v)?;
|
||||
super::paste::insert(s, frag)?;
|
||||
match super::paste::paste_content(s, v)? {
|
||||
super::paste::Pasted::Fragment(frag) => super::paste::insert(s, frag)?,
|
||||
super::paste::Pasted::Text(text) => super::paste::insert_text(s, &text)?,
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! with a richer clipboard) may pass `html`, `rtf` or a `picture` too. Text that is itself RTF or
|
||||
//! HTML source can also be pasted as that format. Paste keeps the source formatting of another
|
||||
//! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687).
|
||||
//! Plain text has no formatting to keep, so Paste inserts it as Keep Text Only does.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -254,25 +255,51 @@ pub fn rich_fragment(s: &mut Session, v: &Value) -> Option<Fragment> {
|
||||
Some(adopt(s, doc))
|
||||
}
|
||||
|
||||
/// What a Paste brings in.
|
||||
pub enum Pasted {
|
||||
/// WordCraft's own copy, or another program's HTML or RTF: it keeps the formatting it carries.
|
||||
Fragment(Fragment),
|
||||
/// The system clipboard's text, lines separated by `\n`.
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else
|
||||
/// another program's formatted copy (HTML, RTF), else the system clipboard's text.
|
||||
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
|
||||
pub fn paste_content(s: &mut Session, v: &Value) -> Result<Pasted, CmdError> {
|
||||
let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n"));
|
||||
if let Some(f) = &s.clipboard
|
||||
&& text.as_ref().is_none_or(|t| f.clipboard_text() == *t)
|
||||
&& (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none()))
|
||||
{
|
||||
return Ok(f.clone());
|
||||
return Ok(Pasted::Fragment(f.clone()));
|
||||
}
|
||||
if let Some(f) = rich_fragment(s, v) {
|
||||
return Ok(f);
|
||||
return Ok(Pasted::Fragment(f));
|
||||
}
|
||||
match text {
|
||||
Some(t) if !t.is_empty() => Ok(Fragment::from_text(&t)),
|
||||
Some(t) if !t.is_empty() => Ok(Pasted::Text(t)),
|
||||
_ => Err(CmdError::Failed("the clipboard is empty".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`paste_content`] as a fragment. Text becomes paragraphs with no formatting of their own.
|
||||
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
|
||||
Ok(match paste_content(s, v)? {
|
||||
Pasted::Fragment(f) => f,
|
||||
Pasted::Text(t) => Fragment::from_text(&t),
|
||||
})
|
||||
}
|
||||
|
||||
/// Paste plain text the way Keep Text Only does, which is what Word does with text that carries
|
||||
/// no formatting: the text takes the formatting before the caret, and each line after the first
|
||||
/// starts a paragraph formatted like the one the text lands in (direction, style, alignment,
|
||||
/// indents, list). Empty lines become empty paragraphs.
|
||||
pub fn insert_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
// Only our own cut, pasted straight after it, becomes a move (see `insert`).
|
||||
s.pending_move = None;
|
||||
super::paste_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))
|
||||
}
|
||||
|
||||
/// Merge Formatting (#687): the pasted text takes the formatting where it lands — the paragraph's
|
||||
/// and the caret's — keeping only its emphasis (bold, italic, underline, strikethrough).
|
||||
pub fn merge_formatting(s: &Session, frag: &mut Fragment) {
|
||||
|
||||
@@ -338,7 +338,33 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||||
/// offers Update Field for the whole table.
|
||||
pub fn caret_in_toc(s: &Session) -> bool {
|
||||
let at = &s.sel.focus;
|
||||
at.story == StoryRef::Body && at.path.depth() == 0 && toc_range(s).is_some_and(|(start, end)| (start..=end).contains(&at.path.last()))
|
||||
if at.story != StoryRef::Body || at.path.depth() != 0 {
|
||||
return false;
|
||||
}
|
||||
if toc_range(s).is_some_and(|(start, end)| (start..=end).contains(&at.path.last())) {
|
||||
return true;
|
||||
}
|
||||
// A saved or imported TOC is a range field spanning its entries until Update Table turns it
|
||||
// into the engine's TOC (#2182, a save and reopen; not the flow in the report). Only the
|
||||
// range Update Table would convert counts, so the menu never refreshes a different TOC.
|
||||
s.doc.field_ranges(StoryRef::Body).into_iter().find(|r| convertible_toc_range(s, r)).is_some_and(|r| {
|
||||
// The caret at the field's own marker (offset 0 of the first entry) and at the end of
|
||||
// the last entry belong to the range as well.
|
||||
(r.start.path.last()..=r.end.path.last()).contains(&at.path.last())
|
||||
})
|
||||
}
|
||||
|
||||
/// The range field `range_toc_to_field` converts: an unlocked, top-level TOC (not a table of
|
||||
/// figures) when the body has no engine TOC yet.
|
||||
fn convertible_toc_range(s: &Session, r: &wordcraft_doc::FieldRange) -> bool {
|
||||
let field = FieldCode::parse(&r.instr);
|
||||
toc_range(s).is_none()
|
||||
&& field.name == "TOC"
|
||||
&& !field.has('c')
|
||||
&& r.depth == 0
|
||||
&& !r.locked
|
||||
&& r.start.path.depth() == 0
|
||||
&& r.end.path.depth() == 0
|
||||
}
|
||||
|
||||
/// A TOC imported as a range field (`FieldStart`…`FieldEnd`: one without a TOC heading, or with
|
||||
@@ -351,16 +377,7 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||||
if toc_range(s).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let is_toc = |instr: &str| {
|
||||
let field = FieldCode::parse(instr);
|
||||
field.name == "TOC" && !field.has('c')
|
||||
};
|
||||
let Some(r) = s
|
||||
.doc
|
||||
.field_ranges(StoryRef::Body)
|
||||
.into_iter()
|
||||
.find(|r| r.depth == 0 && !r.locked && is_toc(&r.instr) && r.start.path.depth() == 0 && r.end.path.depth() == 0)
|
||||
else {
|
||||
let Some(r) = s.doc.field_ranges(StoryRef::Body).into_iter().find(|r| convertible_toc_range(s, r)) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(after_end) = r.end.off.checked_add(OBJ.len_utf8()) else { return Ok(()) };
|
||||
@@ -374,7 +391,13 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||||
if at.path.depth() == 0
|
||||
&& i > 0
|
||||
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
|
||||
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading"))
|
||||
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
|
||||
// field was (the field is saved opening in the first entry), so the next save and update
|
||||
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
|
||||
// range TOC is reused the same way, so deleting it later deletes the TOC field.
|
||||
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
|
||||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
|
||||
})
|
||||
{
|
||||
s.doc.remove_block(StoryRef::Body, &at.path)?;
|
||||
let heading = Path::top(i - 1);
|
||||
@@ -408,7 +431,33 @@ fn toc_field(p: &Paragraph) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The first cached number-separator tab formatting at each TOC level.
|
||||
fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec<TabStop>)> {
|
||||
let span = toc_range(s).map(|(start, end)| (start.saturating_add(1), end)).or_else(|| {
|
||||
s.doc.field_ranges(StoryRef::Body).into_iter().find_map(|r| {
|
||||
let f = FieldCode::parse(&r.instr);
|
||||
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.has('c'))
|
||||
.then_some((r.start.path.last(), r.end.path.last()))
|
||||
})
|
||||
});
|
||||
let Some(blocks) = span.and_then(|(start, end)| s.doc.body.get(start..=end)) else { return Vec::new() };
|
||||
let mut tabs: Vec<(String, Vec<TabStop>)> = Vec::new();
|
||||
for p in blocks.iter().filter_map(|b| b.as_para()) {
|
||||
let Some(style) = &p.props.style else { continue };
|
||||
let Some(direct) = &p.props.tabs else { continue };
|
||||
if !direct.iter().any(|t| matches!(t.align, TabAlign::Left | TabAlign::Center | TabAlign::Decimal)) {
|
||||
continue;
|
||||
}
|
||||
if !style.starts_with("TOC") || style == "TOCHeading" || tabs.iter().any(|(id, _)| id == style) {
|
||||
continue;
|
||||
}
|
||||
tabs.push((style.clone(), direct.clone()));
|
||||
}
|
||||
tabs
|
||||
}
|
||||
|
||||
fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let tabs = cached_toc_tabs(s);
|
||||
range_toc_to_field(s)?;
|
||||
let replace = v.as_object().is_some_and(|m| !m.is_empty());
|
||||
// Replacing a table puts the new one where the old one was, in place of an empty paragraph.
|
||||
@@ -416,7 +465,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = match toc_range(s) {
|
||||
// `{}` updates the table there; settings replace it where it is.
|
||||
Some(_) if !replace => {
|
||||
update_toc(s)?;
|
||||
regenerate_toc(s, None, &tabs)?;
|
||||
return sel_result(s);
|
||||
}
|
||||
Some((start, end)) => {
|
||||
@@ -451,7 +500,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
|
||||
let leader = leader(p::str(v, "tabLeader").unwrap_or("dot"));
|
||||
regenerate_toc(s, Some(leader))?;
|
||||
regenerate_toc(s, Some(leader), &tabs)?;
|
||||
s.clamp_selection();
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -495,12 +544,13 @@ fn bookmark_heading(s: &mut Session, path: &Path, lead: usize) -> Result<String,
|
||||
|
||||
/// Regenerate the TOC entries with page numbers (two layout passes: entries change pagination).
|
||||
pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
|
||||
regenerate_toc(s, None)
|
||||
let tabs = cached_toc_tabs(s);
|
||||
regenerate_toc(s, None, &tabs)
|
||||
}
|
||||
|
||||
/// Rebuild the entries the TOC field's switches ask for. The tab leader is `leader`, or the one
|
||||
/// the entries had (Word keeps it in the entries' tab stops, not in the field).
|
||||
fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdError> {
|
||||
fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, Vec<TabStop>)]) -> Result<(), CmdError> {
|
||||
range_toc_to_field(s)?;
|
||||
let Some((start, end)) = toc_range(s) else { return Ok(()) };
|
||||
let Some(instr) = s.doc.body.get(start).and_then(|b| b.as_para()).and_then(toc_field) else { return Ok(()) };
|
||||
@@ -574,6 +624,9 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
let props = CharProps { link: links.get(k).cloned().flatten(), ..Default::default() };
|
||||
let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1));
|
||||
para.mark = CharProps::default();
|
||||
if e.text.contains('\t') {
|
||||
para.props.tabs = tabs.iter().find(|(style, _)| Some(style) == para.props.style.as_ref()).map(|(_, tabs)| tabs.clone());
|
||||
}
|
||||
if show_page && sep.contains('\t') {
|
||||
let right = TabStop { pos: width - 0.5, align: TabAlign::Right, leader };
|
||||
para.props.tabs = Some(entry_tabs(s, ¶.props, &e.text, right));
|
||||
@@ -591,7 +644,8 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
/// number set off by a space, or no number, a short entry's page tab would stop at one of them
|
||||
/// (without the leader), and so would the tab on the last line of an entry that wraps.
|
||||
fn entry_tabs(s: &Session, props: &wordcraft_doc::ParaProps, text: &str, right: TabStop) -> Vec<TabStop> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut tabs = props.tabs.clone().unwrap_or_default();
|
||||
tabs.retain(|t| t.pos < right.pos - 0.5 && !matches!(t.align, TabAlign::Right | TabAlign::Bar));
|
||||
if !text.contains('\t') {
|
||||
let style = s.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: props.style.clone(), ..Default::default() });
|
||||
for t in style.tabs {
|
||||
@@ -1302,4 +1356,25 @@ mod tests {
|
||||
// Junk values fall back to the caret's story.
|
||||
s.run("references.updateFields", &json!({"all": "yes"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_tabs_ignore_nonentries() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Before\nContents\nOld\t9\nAfter"));
|
||||
let stop = |pos| TabStop { pos, align: TabAlign::Left, leader: TabLeader::None };
|
||||
for (i, pos) in [(0, 96.0), (1, 84.0), (2, 24.0)] {
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
|
||||
p.props.style = Some("TOC2".into());
|
||||
p.props.tabs = Some(vec![stop(pos)]);
|
||||
}
|
||||
s.doc
|
||||
.para_mut(StoryRef::Body, &Path::top(1))
|
||||
.unwrap()
|
||||
.insert_object(
|
||||
0,
|
||||
InlineObject::Field { instr: "TOC".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cached_toc_tabs(&s), vec![("TOC2".into(), vec![stop(24.0)])]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,10 @@ pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &
|
||||
/// installed then lays out and exports with it instead of a fallback. Faces already available
|
||||
/// stay as they are.
|
||||
fn load_embedded_fonts(doc: &Document) {
|
||||
let files: Vec<Vec<u8>> = wordcraft_docx::embedded_fonts(doc).iter().filter_map(|f| wordcraft_fonts::decode_font_file(f).ok()).collect();
|
||||
let files: Vec<(Vec<u8>, String)> = wordcraft_docx::embedded_fonts(doc)
|
||||
.into_iter()
|
||||
.filter_map(|f| wordcraft_fonts::decode_font_file(&f.data).ok().map(|data| (data, f.name)))
|
||||
.collect();
|
||||
if !files.is_empty() {
|
||||
wordcraft_fonts::FontDb::global().add_document_fonts(files);
|
||||
}
|
||||
|
||||
@@ -2686,6 +2686,85 @@ fn imported_range_toc_is_refreshed_in_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2182 (the save and reopen gap, not the flow in the report, which #1197 fixed): after a save
|
||||
/// and reopen a TOC is a range field opening in its first entry. The context menu offers Update
|
||||
/// Field wherever the caret is in it, including the field's own marker (offset 0 of the first
|
||||
/// entry) and the end of the last entry; and repeated update, save and reopen adds no blank line.
|
||||
#[test]
|
||||
fn toc_menu_offers_update_field_after_save_and_reopen() {
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
// What the canvas context menu checks before offering Update Field.
|
||||
let offered = |s: &Session| !crate::cmd::fields::selected(s).is_empty() || crate::cmd::references::caret_in_toc(s);
|
||||
let mut d = wordcraft_doc::Document::from_text("Before.\nOld entry\t9\nAlpha\nBeta");
|
||||
{
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_object(n, InlineObject::FieldEnd, &CharProps::default()).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-2" \h"#.into(), locked: false, dirty: false }, &CharProps::default())
|
||||
.unwrap();
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
for (i, style) in [(2, "Heading 1"), (3, "Heading 2")] {
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
|
||||
run(&mut s, "styles.apply", json!({"style": style}));
|
||||
}
|
||||
let check = |s: &mut Session, what: &str| {
|
||||
let entries: Vec<usize> = (0..s.doc.body.len())
|
||||
.filter(|i| {
|
||||
s.doc.body.get(*i).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC")))
|
||||
})
|
||||
.collect();
|
||||
assert!(!entries.is_empty(), "{what}: no TOC paragraphs: {}", text(s));
|
||||
for (k, i) in entries.iter().enumerate() {
|
||||
let len = s.doc.body.get(*i).and_then(|b| b.as_para()).map_or(0, |p| p.len());
|
||||
// The leftover empty field paragraph before the range (first block) has no menu (minor).
|
||||
if k == 0 && len == 0 {
|
||||
continue;
|
||||
}
|
||||
for off in 0..=len {
|
||||
s.sel = crate::Selection::caret(Pos::body(*i, off));
|
||||
assert!(offered(s), "{what}: no Update Field at block {i} offset {off}: {}", text(s));
|
||||
}
|
||||
}
|
||||
};
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
let mut blocks = None;
|
||||
for round in 0..3 {
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
check(&mut s, &format!("after update {round}"));
|
||||
// No extra blank line per cycle.
|
||||
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "after update {round}: the document grew: {}", text(&s));
|
||||
blocks = Some(s.doc.body.len());
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
check(&mut s, &format!("after reopen {round}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
|
||||
/// range TOC doesn't claim the menu, so it never refreshes a different table.
|
||||
#[test]
|
||||
fn toc_menu_targets_the_toc_update_table_refreshes() {
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let mut d = wordcraft_doc::Document::from_text("One\nTwo\nHead\nFirst\nSecond\nTail");
|
||||
for (para, instr) in [(0, r#"TOC \o "1-2""#), (3, r#"TOC \o "1-3""#)] {
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(para)).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_object(n, InlineObject::FieldEnd, &CharProps::default()).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false }, &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
let at = |s: &mut Session, block: usize| {
|
||||
s.sel = crate::Selection::caret(Pos::body(block, 1));
|
||||
crate::cmd::references::caret_in_toc(s)
|
||||
};
|
||||
assert!(at(&mut s, 0), "the first TOC is the one Update Table converts");
|
||||
assert!(!at(&mut s, 3), "the second range TOC is not offered the menu item");
|
||||
}
|
||||
|
||||
/// Updating a TOC field that had a non-empty result (placeholder from an external generator, #878)
|
||||
/// clears the old result so the exported DOCX nests entries inside the field rather than leaving
|
||||
/// them after the field's end marker.
|
||||
@@ -8083,3 +8162,64 @@ fn toc_collects_cell_headings() {
|
||||
assert!(s.doc.para(StoryRef::Body, &Path::top(1)).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_keeps_direct_number_tabs() {
|
||||
use wordcraft_doc::props::{CharProps, NumRef, TabAlign, TabLeader, TabStop};
|
||||
let left = TabStop { pos: 24.0, align: TabAlign::Left, leader: TabLeader::None };
|
||||
let right = TabStop { pos: 179.5, align: TabAlign::Right, leader: TabLeader::Dot };
|
||||
let mut d = wordcraft_doc::Document::from_text("Contents\n\n1.\tAlpha\t9\nAlpha\nBeta");
|
||||
d.last_section.page_w = 220.0;
|
||||
d.last_section.margin_left = 20.0;
|
||||
d.last_section.margin_right = 20.0;
|
||||
d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
{
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
p.props.style = Some("TOCHeading".into());
|
||||
p.insert_object(
|
||||
p.len(),
|
||||
wordcraft_doc::InlineObject::Field {
|
||||
instr: r#"TOC \o "1-2""#.into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
|
||||
p.props.style = Some("TOC2".into());
|
||||
p.props.tabs = Some(vec![right]);
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(2)).unwrap();
|
||||
p.props.style = Some("TOC2".into());
|
||||
p.props.tabs = Some(vec![left, right]);
|
||||
}
|
||||
for i in [3, 4] {
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
|
||||
p.props.style = Some("Heading2".into());
|
||||
p.props.numbering = Some(NumRef { num, level: 0 });
|
||||
}
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
for command in ["references.updateFields", "references.updateToc", "references.toc"] {
|
||||
run(&mut s, command, json!({}));
|
||||
let layout = s.layout();
|
||||
let mut entries = 0;
|
||||
for (i, b) in s.doc.body.iter().enumerate() {
|
||||
let Some(p) = b.as_para().filter(|p| p.props.style.as_deref() == Some("TOC2")) else { continue };
|
||||
entries += 1;
|
||||
assert!(p.props.tabs.as_ref().is_some_and(|tabs| tabs.contains(&left)), "{command}: {:?}", p.props.tabs);
|
||||
let title = p.text.find('\t').unwrap() + 1;
|
||||
let page = p.text.rfind('\t').unwrap() + 1;
|
||||
let start = layout.caret(&Pos::body(i, title)).unwrap();
|
||||
let end = layout.caret(&Pos::body(i, page)).unwrap();
|
||||
assert_eq!((start.page, start.top), (end.page, end.top), "{command}: {:?}", p.plain_text());
|
||||
assert!((start.x - 44.0).abs() < 1.0, "{command}: {}", start.x);
|
||||
assert!(end.x > 180.0, "{command}: {}", end.x);
|
||||
}
|
||||
assert_eq!(entries, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,3 +174,21 @@ fn fonts_embedded_in_a_docx_lay_out_its_text() {
|
||||
assert!((x - mono).abs() < 0.01, "{family}: measured with the embedded font: {x} vs {mono} (missing {missing})");
|
||||
}
|
||||
}
|
||||
|
||||
/// A font Word embeds as a subset ("embed only the characters used") goes by a made-up family
|
||||
/// inside (`___WRD_EMBED_SUB_46`), and the text asks for it by the font table's name
|
||||
/// (`OBRBWW+Century Schoolbook`): the text is measured with it all the same (#2240). It fell back
|
||||
/// to a substitute, as if nothing were embedded.
|
||||
#[test]
|
||||
fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() {
|
||||
const TEXT: &str = "illicit little lilies fill tiny jill";
|
||||
const FAMILY: &str = "QWERTY+JetBrains Sub";
|
||||
let end_x =
|
||||
|bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x;
|
||||
let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT));
|
||||
let missing = end_x(&docx_embedding(FAMILY, None, TEXT));
|
||||
assert!((missing - mono).abs() > 1.0, "not embedded, the text falls back: {missing} vs {mono}");
|
||||
let subset = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", "___WRD_EMBED_7");
|
||||
let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT));
|
||||
assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})");
|
||||
}
|
||||
|
||||
@@ -1141,11 +1141,16 @@ fn reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted() {
|
||||
fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() {
|
||||
// Issue #422: the pasted paragraph separators went through Enter, which ends the list on an
|
||||
// empty item, so `A\n\nB` lost its empty paragraph.
|
||||
for (id, v) in [("edit.pasteText", json!({"text": "A\n\nB"})), ("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"}))] {
|
||||
// Paste of plain text from another program goes the same way.
|
||||
for (id, v) in [
|
||||
("edit.pasteText", json!({"text": "A\n\nB"})),
|
||||
("edit.pasteSpecial", json!({"as": "text", "text": "A\n\nB"})),
|
||||
("edit.paste", json!({"text": "A\n\nB"})),
|
||||
] {
|
||||
let mut s = s();
|
||||
run(&mut s, "para.bullets", json!({}));
|
||||
run(&mut s, id, v);
|
||||
assert_eq!(texts(&s), ["a", "", "b"], "{id}");
|
||||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("", Some(0)), para("B", Some(0))], "{id}");
|
||||
}
|
||||
// Enter twice still ends the list.
|
||||
let mut s = s();
|
||||
@@ -1154,6 +1159,60 @@ fn plain_text_paste_keeps_an_empty_paragraph_in_a_list() {
|
||||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", None)]);
|
||||
}
|
||||
|
||||
/// Each body paragraph's text and paragraph formatting.
|
||||
fn para_formats(s: &Session) -> Vec<(String, wordcraft_doc::props::ParaProps)> {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.clone())).collect()
|
||||
}
|
||||
|
||||
/// Plain text from another program pastes as Keep Text Only does, as in Word: every pasted line
|
||||
/// becomes a paragraph formatted like the paragraph it lands in. Paste gave the lines after the
|
||||
/// first default formatting, and the text after the caret went with the last line, so a
|
||||
/// right-to-left paragraph came out left-to-right from its second line on.
|
||||
#[test]
|
||||
fn plain_text_paste_gives_every_line_the_formatting_of_its_paragraph() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "بداية نهاية", "raw": true}));
|
||||
run(&mut s, "para.rtl", json!({}));
|
||||
run(&mut s, "para.indents", json!({"left": 18, "right": 36}));
|
||||
run(&mut s, "para.spacing", json!({"after": 12}));
|
||||
let before = para_formats(&s);
|
||||
let [(_, props)] = &before[..] else { panic!("{before:?}") };
|
||||
assert_eq!(props.bidi, Some(true));
|
||||
let off = "بداية ".len();
|
||||
for id in ["edit.paste", "edit.pasteText"] {
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": off}}));
|
||||
run(&mut s, id, json!({"text": "سطر أول\r\nسطر ثان\r\nسطر ثالث"}));
|
||||
let want: Vec<(String, _)> = ["بداية سطر أول", "سطر ثان", "سطر ثالثنهاية"].iter().map(|t| (t.to_string(), props.clone())).collect();
|
||||
assert_eq!(para_formats(&s), want, "{id}");
|
||||
// One undo step takes the whole paste out again.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(para_formats(&s), before, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty lines in pasted plain text come through as empty paragraphs, formatted like the rest,
|
||||
/// and so does a line break at the end of the text. The text takes the formatting before the
|
||||
/// caret.
|
||||
#[test]
|
||||
fn plain_text_paste_keeps_empty_lines_as_paragraphs() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Lead ", "raw": true}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
run(&mut s, "para.spacing", json!({"before": 6, "after": 18}));
|
||||
let props = para_formats(&s)[0].1.clone();
|
||||
run(&mut s, "edit.paste", json!({"text": "Alpha one.\n\nAlpha two.\n\n\nAlpha three.\n"}));
|
||||
let got = para_formats(&s);
|
||||
let texts: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(texts, ["Lead Alpha one.", "", "Alpha two.", "", "", "Alpha three.", ""]);
|
||||
assert!(got.iter().all(|(_, p)| *p == props), "{got:?}");
|
||||
assert_eq!(s.sel.focus, Pos::body(6, 0), "the caret ends after the pasted text");
|
||||
let bold = s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.runs.iter().all(|r| r.props.bold == Some(true)));
|
||||
assert!(bold, "the pasted text takes the bold before the caret");
|
||||
}
|
||||
|
||||
/// #713: Arabic or Hebrew typed into an empty paragraph makes it right-to-left; a paragraph that
|
||||
/// already has letters, or whose style sets a direction, keeps its own.
|
||||
#[test]
|
||||
|
||||
@@ -377,6 +377,9 @@ struct SysFallback {
|
||||
tried: std::collections::HashSet<&'static str>,
|
||||
}
|
||||
|
||||
/// The longest name a document's font table gives an embedded font that faces take on.
|
||||
const MAX_FAMILY_NAME: usize = 128;
|
||||
|
||||
/// Characters remembered as covered by no installed font (a cap on memory: once full the set
|
||||
/// starts over, and the coverage index answers again quickly).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1133,20 +1136,26 @@ impl FontDb {
|
||||
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
|
||||
/// every face was already present).
|
||||
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
|
||||
self.add_fonts(vec![bytes])
|
||||
self.add_fonts(vec![(bytes, None)])
|
||||
}
|
||||
|
||||
/// Add the faces of several font files at once: lookups see all of them or none, never a
|
||||
/// family with only some of its styles (which would resolve, and be cached as, the wrong
|
||||
/// face). Faces already present (same family and style) are skipped. Returns how many were
|
||||
/// added.
|
||||
fn add_fonts(&self, files: Vec<Vec<u8>>) -> usize {
|
||||
/// face). A file given a `name` its faces don't go by (a document's font table's, see
|
||||
/// [`FontDb::add_document_fonts`]) has them go by that name instead. Faces already present
|
||||
/// (same family and style) are skipped. Returns how many were added.
|
||||
fn add_fonts(&self, files: Vec<(Vec<u8>, Option<String>)>) -> usize {
|
||||
let mut new = Vec::new();
|
||||
for bytes in files {
|
||||
for (bytes, name) in files {
|
||||
let data = Arc::new(bytes);
|
||||
let name: Option<String> = name.map(|n| n.trim().chars().take(MAX_FAMILY_NAME).collect()).filter(|n: &String| !n.is_empty());
|
||||
for f in enumerate_faces(&data) {
|
||||
if let Some(mut face) = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
face.local_names = f.local;
|
||||
if let Some(n) = name.as_deref().filter(|n| !face.named(n) && !face.local_names.iter().any(|l| l.name.eq_ignore_ascii_case(n))) {
|
||||
face.family = n.to_string();
|
||||
face.legacy_family = n.to_string();
|
||||
}
|
||||
new.push(Arc::new(face));
|
||||
}
|
||||
}
|
||||
@@ -1260,7 +1269,7 @@ impl FontDb {
|
||||
p.dedup();
|
||||
p
|
||||
};
|
||||
let files = paths.iter().filter_map(|p| std::fs::read(p).ok()).collect();
|
||||
let files = paths.iter().filter_map(|p| std::fs::read(p).ok()).map(|b| (b, None)).collect();
|
||||
self.add_fonts(files) > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -245,10 +245,13 @@ impl FontDb {
|
||||
|
||||
/// Add the fonts a document embeds (#1171) for this run: not recorded as user fonts, never
|
||||
/// stored. Faces already available are kept. Lookups made while the family was missing are
|
||||
/// forgotten ([`FontDb::generation`]), so text set in it now resolves to it. Returns how many
|
||||
/// faces were added.
|
||||
pub fn add_document_fonts(&self, files: Vec<Vec<u8>>) -> usize {
|
||||
let added = self.add_fonts(files);
|
||||
/// forgotten ([`FontDb::generation`]), so text set in it now resolves to it. Each file comes
|
||||
/// with the name the document's font table gives it, which its faces answer to as well: Word
|
||||
/// embeds a subset ("embed only the characters used") under a made-up family
|
||||
/// (`___WRD_EMBED_SUB_46`), and the text asks for it by the table's name (#2240). Returns how
|
||||
/// many faces were added.
|
||||
pub fn add_document_fonts(&self, files: Vec<(Vec<u8>, String)>) -> usize {
|
||||
let added = self.add_fonts(files.into_iter().map(|(data, name)| (data, Some(name))).collect());
|
||||
if added > 0 {
|
||||
self.fonts_changed();
|
||||
}
|
||||
|
||||
@@ -587,7 +587,7 @@ fn circle_without_text_face() {
|
||||
fn circle_checks_face_coverage() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
db.add_document_fonts(vec![named_face("SourceSerif4-Regular.ttf", "Times New Roman")]);
|
||||
db.add_document_fonts(vec![(named_face("SourceSerif4-Regular.ttf", "Times New Roman"), "Times New Roman".to_string())]);
|
||||
let preferred = db.face("Times New Roman", "Regular");
|
||||
assert!(!preferred.covers('●'));
|
||||
let primary = db.face("Source Serif 4", "Regular");
|
||||
|
||||
@@ -1056,6 +1056,9 @@ fn layout_box(
|
||||
let mut prev_listed = false;
|
||||
let mut prev_style: Option<(String, bool)> = None;
|
||||
let mut last_top = 0.0f32;
|
||||
// Word has no frames in a text box: a framed paragraph there (its style's frame, usually)
|
||||
// runs with the box's text (#2357).
|
||||
let frames = !matches!(story, StoryRef::Part(id) if ctx.doc.parts.get(&id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::TextBox));
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
last_top = y;
|
||||
let mut path = prefix.to_vec();
|
||||
@@ -1068,7 +1071,7 @@ fn layout_box(
|
||||
// A text frame (a footer's page number in a frame, say) is placed apart, like a
|
||||
// floating object, and the text after it wraps around it; it takes no room in
|
||||
// the flow.
|
||||
if let Some(fr) = pl.rp.frame.clone() {
|
||||
if let Some(fr) = pl.rp.frame.clone().filter(|_| frames) {
|
||||
let fw = fr.w.unwrap_or_else(|| natural_width(&pl)).clamp(1.0, 1584.0);
|
||||
let pf = if (fw - width).abs() > 0.01 { ctx.para_labelled(p, fw, table, &[], label) } else { pl };
|
||||
let h = match fr.h {
|
||||
@@ -3159,6 +3162,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
if pb.at_top() && (after_break_mark || (broke_before && ctx.doc.settings.compat_mode >= 15)) {
|
||||
before = 0.0;
|
||||
} else if pb.at_top()
|
||||
&& !ctx.doc.settings.sum_paragraph_spacing
|
||||
&& let Some((_, _, after, _, _)) = &prev_para
|
||||
{
|
||||
// After a page break before or a section break the space between the paragraphs is
|
||||
|
||||
@@ -616,6 +616,13 @@ pub(crate) fn push_caps(rc: &ResolvedChar, c: char, out: &mut String) {
|
||||
if rc.caps || (rc.small_caps && c.is_lowercase()) { out.extend(c.to_uppercase()) } else { out.push(c) }
|
||||
}
|
||||
|
||||
/// The OpenType ligature features a `w14:ligatures` value sets, each on or off. Required
|
||||
/// ligatures (`rlig`, Arabic lam-alef) are never turned off.
|
||||
pub(crate) fn ligature_features(l: wordcraft_doc::props::Ligatures) -> [wordcraft_fonts::Feature; 4] {
|
||||
let f = |tag: &[u8; 4], on: bool| wordcraft_fonts::feature_value(tag, on as u32);
|
||||
[f(b"liga", l.standard), f(b"clig", l.contextual), f(b"hlig", l.historical), f(b"dlig", l.discretional)]
|
||||
}
|
||||
|
||||
/// Whether `c`'s capital is more than one character (ß → SS), so All Caps draws more
|
||||
/// characters than the document holds.
|
||||
fn caps_expands(c: char) -> bool {
|
||||
@@ -1020,7 +1027,18 @@ impl<'a> Builder<'a> {
|
||||
}
|
||||
};
|
||||
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
|
||||
let features: &[wordcraft_fonts::Feature] = if rc.kerned() { &[] } else { &no_kern };
|
||||
// Without `w14:ligatures` the shaper's default (standard and contextual) stays.
|
||||
let [kern_off] = no_kern;
|
||||
let all = match rc.ligatures {
|
||||
Some(l) => {
|
||||
let [a, b, c, d] = ligature_features(l);
|
||||
[kern_off, a, b, c, d]
|
||||
}
|
||||
None => [kern_off; 5],
|
||||
};
|
||||
let from = usize::from(rc.kerned());
|
||||
let to = if rc.ligatures.is_some() { all.len() } else { 1 };
|
||||
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
|
||||
let shaped = if use_levels {
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
|
||||
} else {
|
||||
@@ -1827,9 +1845,11 @@ fn ptab_stop(align: PTabAlign, base: PTabBase, leader: TabLeader, width: f32, in
|
||||
}
|
||||
|
||||
fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Option<&Level>) {
|
||||
// Narrow cells and float spans must retain their usable width.
|
||||
const MIN_LINE_WIDTH: f32 = 1.0;
|
||||
let suffix = level.map(|l| l.suffix);
|
||||
let rp = pl.rp.clone();
|
||||
let width = env.width.max(12.0);
|
||||
let width = env.width.max(MIN_LINE_WIDTH);
|
||||
let base_right = (width - rp.indent_right).max(1.0);
|
||||
let default_tab = env.doc.settings.default_tab;
|
||||
let first_left = rp.indent_left + rp.indent_first;
|
||||
@@ -1911,7 +1931,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
narrowed = !rest.is_empty() || a > lo || b < base_right;
|
||||
row_rest = rest;
|
||||
row_first = lines.len();
|
||||
break (a, b.max(a + 12.0));
|
||||
break (a, b.max(a + MIN_LINE_WIDTH));
|
||||
}
|
||||
Err(below) if below > top && guard < 50 => {
|
||||
top = below;
|
||||
@@ -1919,7 +1939,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
}
|
||||
Ok((None, _)) | Err(_) => {
|
||||
row_first = lines.len();
|
||||
break (lo, base_right.max(lo + 12.0));
|
||||
break (lo, base_right.max(lo + MIN_LINE_WIDTH));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2199,7 +2219,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
// Spaces and tabs never make a line taller, whatever their size: Word sizes a line by
|
||||
// its other characters (a line of only them takes the paragraph mark's), measured in
|
||||
// Word for Windows with 9 pt text and 11–20 pt tabs, spaces and ideographic spaces.
|
||||
if matches!(c.kind, ClKind::Marker | ClKind::Space | ClKind::Tab) || c0 + k < dropped {
|
||||
// Floating and hidden drawings have no inline extent; their anchor font takes no height.
|
||||
let anchor = matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0;
|
||||
if matches!(c.kind, ClKind::Marker | ClKind::Space | ClKind::Tab) || anchor || c0 + k < dropped {
|
||||
continue;
|
||||
}
|
||||
if let Some(st) = pl.styles.get(c.style as usize) {
|
||||
@@ -2207,8 +2229,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
|
||||
// and descent, and grows above the baseline to fit a taller picture.
|
||||
let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h > 0.0 {
|
||||
if pl.maths.iter().any(|(k, _)| c.kind == ClKind::Object(*k)) {
|
||||
// An equation is text: its ascent and descent count like the text's.
|
||||
(c.obj_h, c.obj_d)
|
||||
// Exact spacing keeps the run's baseline; tall math may overflow the
|
||||
// fixed line box without moving adjacent text up by its denominator.
|
||||
if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) }
|
||||
} else {
|
||||
obj_asc = obj_asc.max(c.obj_h);
|
||||
(st.ascent, st.descent)
|
||||
|
||||
@@ -111,7 +111,8 @@ fn default_margins(t: &Table, style: Option<&TableStyleProps>) -> [f32; 4] {
|
||||
/// bottom margins.
|
||||
pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell: usize) -> [f32; 4] {
|
||||
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
|
||||
t.rows.get(row).and_then(|r| r.cells.get(cell)).and_then(|c| c.props.margins).unwrap_or_else(|| default_margins(t, style.as_ref()))
|
||||
let defaults = default_margins(t, style.as_ref());
|
||||
t.rows.get(row).and_then(|r| r.cells.get(cell)).map_or(defaults, |cell| cell.props.resolved_margins(defaults))
|
||||
}
|
||||
|
||||
/// The most paragraphs [`measure_table_columns`] lays out: a huge table is measured by its first
|
||||
@@ -183,7 +184,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let span = cell.span();
|
||||
let margins = cell.props.margins.unwrap_or(margins_def);
|
||||
let margins = cell.props.resolved_margins(margins_def);
|
||||
let side = margins[1].max(0.0) + margins[3].max(0.0);
|
||||
let text = style.as_ref().map(|st| region_text(st, region_of(t, style.as_ref(), ri, g, span, ncols)));
|
||||
let env = crate::para::ParaEnv {
|
||||
@@ -286,7 +287,7 @@ pub(crate) fn first_cell_left_margin(ctx: &Ctx, t: &Table) -> f32 {
|
||||
}
|
||||
|
||||
fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
|
||||
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
|
||||
t.rows.first().and_then(|r| r.cells.first()).map_or(def, |cell| cell.props.resolved_margins(def))[1]
|
||||
}
|
||||
|
||||
/// What a table style gives the cells in one set of parts: shading, vertical alignment and each
|
||||
@@ -470,7 +471,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let (row_top, row_bottom) = row
|
||||
.cells
|
||||
.iter()
|
||||
.map(|c| c.props.margins.unwrap_or(margins_def))
|
||||
.map(|c| c.props.resolved_margins(margins_def))
|
||||
.fold((0.0f32, 0.0f32), |(top, bottom), m| (top.max(m[0]), bottom.max(m[2])));
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
let span = cell.span();
|
||||
@@ -479,7 +480,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
// Right to left: grid columns run from the right edge, and the start (left) margin
|
||||
// is on the cell's right.
|
||||
let (x0, x1) = if rtl { ((acc - x1).max(0.0), (acc - x0).max(0.0)) } else { (x0, x1) };
|
||||
let m = cell.props.margins.unwrap_or(margins_def);
|
||||
let m = cell.props.resolved_margins(margins_def);
|
||||
let margins = if rtl { [row_top, m[3], row_bottom, m[1]] } else { [row_top, m[1], row_bottom, m[3]] };
|
||||
let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0);
|
||||
let region = region_of(t, style.as_ref(), ri, g, span, ncols);
|
||||
@@ -1075,7 +1076,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
let text = style.map(|st| region_text(st, region_of(t, style, ri, g, cell.span(), cols.len())));
|
||||
let (lo, hi) = blocks_content(ctx, &cell.blocks, text.as_ref(), depth);
|
||||
any |= hi > 0.0;
|
||||
let m = cell.props.margins.unwrap_or(margins_def);
|
||||
let m = cell.props.resolved_margins(margins_def);
|
||||
let need = (lo + m[1] + m[3], hi + m[1] + m[3]);
|
||||
match cols.get_mut(g) {
|
||||
Some(col) if span == 1 => *col = (col.0.max(need.0), col.1.max(need.1)),
|
||||
|
||||
@@ -952,6 +952,44 @@ fn positional_tab_after_only_a_bookmark_stays_on_its_line() {
|
||||
assert!((x - 108.0).abs() < 0.5, "X at the indent: {x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_cells_keep_their_width() {
|
||||
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);
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p.clone())];
|
||||
let wide = lay(&d);
|
||||
let advance = wide.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { para, .. } => Some(para.clusters.iter().map(|c| c.adv).sum::<f32>()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let narrow = advance - 0.25;
|
||||
assert!(narrow > 6.0 && advance < 12.0, "probe width {advance}");
|
||||
for (width, expected_lines) in [(narrow, 2), (1.0, 2), (12.0, 1)] {
|
||||
let mut table = Table::new(1, 1, width);
|
||||
table.props.style = None;
|
||||
table.props.fixed = true;
|
||||
table.props.cell_margins = Some([0.0; 4]);
|
||||
table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p.clone())];
|
||||
d.body = vec![std::sync::Arc::new(Block::Table(table))];
|
||||
let l = lay(&d);
|
||||
let lines = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { para, .. } => Some(para.lines.len()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(lines, expected_lines, "cell width {width}, text width {advance}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_get_labels_and_indent() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
@@ -6165,6 +6203,29 @@ fn a_text_frame_in_a_cell_floats_beside_the_text() {
|
||||
assert!(frame.x > text.x + 200.0, "right-aligned in the cell: {} vs {}", frame.x, text.x);
|
||||
}
|
||||
|
||||
/// Word has no frames inside a text box: a paragraph there whose style carries a frame (a
|
||||
/// letterhead's "Weld" and "Governor" styles, framed at the page's left) runs in the box's text
|
||||
/// like any other. WordCraft placed each one apart at the frame's offset, so two of them in a row
|
||||
/// were drawn over each other (#2357).
|
||||
#[test]
|
||||
fn a_frame_inside_a_text_box_flows_with_its_text() {
|
||||
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());
|
||||
let frame = TextFrame { x: 27.3, y: 7.05, h_anchor: "page".into(), v_anchor: "text".into(), wrap: "notBeside".into(), ..Default::default() };
|
||||
let para = |text: &str, framed: bool| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default());
|
||||
p.props.frame = framed.then(|| frame.clone());
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
d.parts.get_mut(&id).unwrap().blocks = vec![para("Name", true), para("Secretary", true), para("Commissioner", false)];
|
||||
let l = lay(&d);
|
||||
let top = |i: usize| l.caret(&Pos { story: StoryRef::Part(id), path: Path::top(i), off: 0 }).unwrap().top;
|
||||
let (name, secretary, commissioner) = (top(0), top(1), top(2));
|
||||
assert!(secretary > name + 5.0, "the second framed paragraph goes under the first: {secretary} vs {name}");
|
||||
assert!(commissioner > secretary + 5.0, "the text after them follows: {commissioner} vs {secretary}");
|
||||
}
|
||||
|
||||
/// Where a list paragraph's text starts when its style has indents of its own (measured in Word,
|
||||
/// `plan/word` R16): the list level's indents go on top of the most derived style that gives the
|
||||
/// list, so that style's own indent (and those of styles based on it) win, its base styles' don't
|
||||
@@ -6613,6 +6674,57 @@ fn kerning_follows_kerning_for_fonts_like_word() {
|
||||
assert!(d.styles.resolve_char(None, &Default::default()).kern == 0.0, "no Kerning for fonts: not kerned");
|
||||
}
|
||||
|
||||
/// The ligatures a document asks for (`w14:ligatures`, Word's Font ▸ Advanced ▸ Ligatures) are
|
||||
/// the ones shaped: `none` draws every letter, `standard` turns them back on over a style that
|
||||
/// turned them off. Without a value WordCraft keeps its default, standard and contextual.
|
||||
#[test]
|
||||
fn ligatures_follow_w14_ligatures() {
|
||||
use wordcraft_doc::props::{CharProps, Ligatures};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
let text = "office affinity";
|
||||
let face = wordcraft_fonts::word::resolve("Source Serif 4", false, false).face;
|
||||
assert_eq!(wordcraft_fonts::shape(&face, "fi", &[], |c| c).len(), 1, "the test font draws fi as one glyph");
|
||||
let glyphs = |style: Option<Ligatures>, run: Option<Ligatures>| {
|
||||
let mut d = Document::new();
|
||||
d.styles.upsert(Style {
|
||||
id: "Plain".into(),
|
||||
name: "Plain".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: CharProps { ligatures: style, ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let chr =
|
||||
CharProps { style: Some("Plain".into()), font: Some("Source Serif 4".into()), size: Some(14.0), ligatures: run, ..Default::default() };
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))];
|
||||
let l = lay(&d);
|
||||
l.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.items)
|
||||
.map(|it| if let Placed::Lines { story: StoryRef::Body, para, .. } = it { para.glyphs.len() } else { 0 })
|
||||
.sum::<usize>()
|
||||
};
|
||||
let letters = text.chars().count();
|
||||
let none = Ligatures::from_ooxml("none");
|
||||
let standard = Ligatures::from_ooxml("standard");
|
||||
assert!(glyphs(None, None) < letters, "WordCraft's default draws ligatures");
|
||||
assert_eq!(glyphs(None, none), letters, "none: one glyph per letter");
|
||||
assert_eq!(glyphs(none, None), letters, "none from the character style");
|
||||
assert!(glyphs(none, standard) < letters, "standard over a style with none draws them again");
|
||||
assert_eq!(glyphs(None, Ligatures::from_ooxml("contextual")), letters, "fi is a standard ligature, not a contextual one");
|
||||
}
|
||||
|
||||
/// Each flag of `w14:ligatures` sets its OpenType feature: standard `liga`, contextual `clig`,
|
||||
/// historical `hlig`, discretional `dlig`.
|
||||
#[test]
|
||||
fn ligature_flags_map_to_opentype_features() {
|
||||
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() };
|
||||
assert_eq!(para::ligature_features(hd), [f(b"liga", 0), f(b"clig", 0), f(b"hlig", 1), f(b"dlig", 1)]);
|
||||
let sc = Ligatures { standard: true, contextual: true, ..Default::default() };
|
||||
assert_eq!(para::ligature_features(sc), [f(b"liga", 1), f(b"clig", 1), f(b"hlig", 0), f(b"dlig", 0)]);
|
||||
}
|
||||
|
||||
/// Word's automatic (HTML-style) paragraph spacing, measured in Word for Windows: 14 pt at any
|
||||
/// size; none before the first paragraph of the document or a cell, none after the last one of a
|
||||
/// cell; next to another paragraph's space the larger of the two counts.
|
||||
@@ -8004,3 +8116,133 @@ fn cell_frame_centres_empty() {
|
||||
assert!((frames[0].y - frames[1].y).abs() < 0.01, "frames must align: {frames:?}");
|
||||
assert!((frames[0].y - 66.5).abs() < 0.5, "Word frame top: {frames:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_start_summed_spacing() {
|
||||
for summed in [false, true] {
|
||||
let mut d = Document::from_text("Section end\nSection start");
|
||||
d.settings.sum_paragraph_spacing = summed;
|
||||
let first = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
first.props.space_after = Some(10.0);
|
||||
first.section = Some(Box::default());
|
||||
let next = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
next.props.space_before = Some(24.0);
|
||||
let l = lay(&d);
|
||||
let top = l.pages[1]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| match it {
|
||||
Placed::Lines { path, y, .. } if *path == Path::top(1) => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let gap = if summed { 24.0 } else { 14.0 };
|
||||
assert!((top - d.last_section.margin_top - gap).abs() < 0.01, "summed={summed}: {top}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_margins_inherit() {
|
||||
let height = |top, bottom| {
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.props.style = None;
|
||||
table.props.cell_margins = Some([top, 2.25, bottom, 2.75]);
|
||||
let cell = &mut table.rows[0].cells[0];
|
||||
cell.props.margins = None;
|
||||
cell.props.partial_margins = Some([None, Some(0.0), None, None]);
|
||||
let mut para = Paragraph::with_text("Point", Default::default());
|
||||
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(11.0));
|
||||
para.props.space_before = Some(0.0);
|
||||
para.props.space_after = Some(0.0);
|
||||
cell.blocks = vec![wordcraft_doc::para_block(para)];
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![std::sync::Arc::new(Block::Table(table))];
|
||||
let layout = lay(&doc);
|
||||
layout.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Cell { rect, .. } => Some(rect.h),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert!((height(2.75, 2.75) - 16.5).abs() < 0.01);
|
||||
assert!((height(6.0, 8.0) - 25.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Exact line pitch keeps the text baseline even when an equation has a deep denominator.
|
||||
#[test]
|
||||
fn exact_math_keeps_run_baseline() {
|
||||
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)] {
|
||||
let mut baseline = Vec::new();
|
||||
let mut following = Vec::new();
|
||||
for equation in [None, Some("a/(b/c)")] {
|
||||
let chr = wordcraft_doc::CharProps { font: Some(wordcraft_doc::styles::BODY_FONT.into()), size: Some(12.0), ..Default::default() };
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.props.line_spacing = Some(spacing);
|
||||
match equation {
|
||||
None => {
|
||||
p.insert_text(0, "x", &chr).unwrap();
|
||||
}
|
||||
Some(text) => p
|
||||
.insert_object(
|
||||
0,
|
||||
InlineObject::Equation {
|
||||
linear: text.into(),
|
||||
display,
|
||||
math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(text), ..Default::default() },
|
||||
},
|
||||
&chr,
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
let mut d = Document::from_text("After");
|
||||
d.body.insert(0, wordcraft_doc::para_block(p));
|
||||
let l = lay(&d);
|
||||
let (y, para) = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| match it {
|
||||
Placed::Lines { path, y, para, .. } if *path == Path::top(0) => Some((*y, para)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
baseline.push(y + para.lines[0].baseline);
|
||||
following.push(para_at(&l, &[1])[0].1);
|
||||
}
|
||||
match spacing {
|
||||
LineSpacing::Exactly(_) => {
|
||||
assert!((baseline[0] - baseline[1]).abs() < 0.01, "display={display}: {baseline:?}");
|
||||
assert!((following[0] - following[1]).abs() < 0.01, "fixed pitch: {following:?}");
|
||||
}
|
||||
_ => assert!(following[1] > following[0], "minimum pitch grows for math: {following:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_font_keeps_line_height() {
|
||||
use wordcraft_doc::CharProps;
|
||||
use wordcraft_doc::para::{Float, Wrap};
|
||||
|
||||
let pitch = |wrap, size| {
|
||||
let small = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Anchor", small.clone());
|
||||
p.mark = small;
|
||||
p.props.space_after = Some(0.0);
|
||||
p.insert_object(0, picture(1.0, 1.0, Float { wrap, ..Default::default() }), &CharProps { size: Some(size), ..Default::default() }).unwrap();
|
||||
let mut d = Document::from_text("After");
|
||||
d.body.insert(0, wordcraft_doc::para_block(p));
|
||||
let l = lay(&d);
|
||||
l.caret(&Pos::body(1, 0)).unwrap().top - l.caret(&Pos::body(0, 0)).unwrap().top
|
||||
};
|
||||
|
||||
for wrap in [Wrap::BehindText, Wrap::InFrontOfText] {
|
||||
assert!((pitch(wrap, 40.0) - pitch(wrap, 9.0)).abs() < 0.01, "{wrap:?}");
|
||||
}
|
||||
assert!(pitch(Wrap::Inline, 40.0) > pitch(Wrap::Inline, 9.0));
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ zbus = "=5.19.0"
|
||||
js-sys = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
cfb = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
zip = { workspace = true }
|
||||
|
||||
|
||||
@@ -4179,6 +4179,59 @@ mod tests {
|
||||
assert!(!h.state().session.doc.body.iter().any(|b| is_entry(b)), "the table is gone");
|
||||
}
|
||||
|
||||
/// #2182: after Update Field has refreshed a table of contents once, the context menu on it
|
||||
/// still offers Update Field, so a later heading change can be brought in again.
|
||||
#[test]
|
||||
fn context_menu_on_a_toc_still_offers_update_field_after_an_update() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use wordcraft_doc::{Block, Paragraph, Path, props::CharProps};
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::report()), Default::default());
|
||||
a.run("caret.docStart", json!({})).unwrap();
|
||||
a.run("references.toc", json!({})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for round in 0..2 {
|
||||
let name = format!("Fresh heading {round}");
|
||||
let end = h.state().session.doc.body.len();
|
||||
let heading = Paragraph::with_text(&name, CharProps::default()).styled("Heading1");
|
||||
h.state_mut().session.doc.insert_block(StoryRef::Body, &Path::top(end), Block::Para(heading)).unwrap();
|
||||
h.state_mut().session.relayout();
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let entry = h
|
||||
.state()
|
||||
.session
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.position(|b| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOC1")))
|
||||
.expect("a TOC entry");
|
||||
let c = h.state_mut().session.layout().caret(&Pos::body(entry, 2)).expect("the entry is laid out");
|
||||
let at = page_to_screen(h.state_mut(), c.page, c.x + 1.0, c.top + c.height / 2.0).expect("on screen");
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
h.step();
|
||||
assert!(h.query_all_by_label_contains("Paste").next().is_some(), "round {round}: the context menu is open");
|
||||
assert!(h.query_by_label_contains("Update Field").is_some(), "round {round}: Update Field is offered");
|
||||
h.get_by_label_contains("Update Field").click();
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let text = h.state().session.doc.plain_text(StoryRef::Body);
|
||||
assert_eq!(text.matches(&name).count(), 2, "round {round}: the new heading is listed: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #996: on macOS a Control-click is the right-click (the trackpad's and the one-button
|
||||
/// mouse's way to it), so it opens the same menu and leaves the caret where the menu needs it.
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -1354,6 +1354,21 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
close = body(app, ui, &mut d);
|
||||
});
|
||||
if let Some(shown) = &shown {
|
||||
if let Dialog::SaveChanges { name, message, .. } = &d {
|
||||
let question = crate::i18n::fmt(tl!("Do you want to save changes to {name}?"), &[("name", name)]);
|
||||
let mut description = tl!("Your changes will be lost if you don't save them.").to_string();
|
||||
if !message.is_empty() {
|
||||
description.push('\n');
|
||||
description.push_str(message);
|
||||
}
|
||||
// This is an urgent decision, not another application window. Keep its context with
|
||||
// the focused choice, including when a failed Save brings the same prompt back.
|
||||
ctx.accesskit_node_builder(shown.response.id, |node| {
|
||||
node.set_role(egui::accesskit::Role::AlertDialog);
|
||||
node.set_label(question);
|
||||
node.set_description(description);
|
||||
});
|
||||
}
|
||||
count_dialog_frame(ctx, id);
|
||||
close |= crate::window_close::show(ctx, &shown.response, &Tokens::get(ctx));
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ pub(crate) struct FileBrowser {
|
||||
impl FileBrowser {
|
||||
pub(crate) fn new(req: FileDialogRequest, after: AfterPick, start: PathBuf, unavailable: bool) -> Self {
|
||||
let (name, filters) = match &req {
|
||||
FileDialogRequest::Open { purpose } => {
|
||||
FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => {
|
||||
(String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect())
|
||||
}
|
||||
FileDialogRequest::Save { name } => {
|
||||
@@ -209,7 +209,7 @@ impl FileBrowser {
|
||||
return Step::Stay;
|
||||
}
|
||||
match self.req {
|
||||
FileDialogRequest::Open { .. } => {
|
||||
FileDialogRequest::Open { .. } | FileDialogRequest::OpenMany { .. } => {
|
||||
if path.is_file() {
|
||||
return Step::Pick(path);
|
||||
}
|
||||
@@ -242,14 +242,14 @@ impl FileBrowser {
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
match self.req {
|
||||
FileDialogRequest::Open { .. } => "Open",
|
||||
FileDialogRequest::Open { .. } | FileDialogRequest::OpenMany { .. } => "Open",
|
||||
FileDialogRequest::Save { .. } => "Save As",
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_label(&self) -> &'static str {
|
||||
match self.req {
|
||||
FileDialogRequest::Open { .. } => "Open",
|
||||
FileDialogRequest::Open { .. } | FileDialogRequest::OpenMany { .. } => "Open",
|
||||
FileDialogRequest::Save { .. } => "Save",
|
||||
}
|
||||
}
|
||||
@@ -280,7 +280,10 @@ impl WordApp {
|
||||
let (req, after) = match purpose {
|
||||
"picture" => (FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture),
|
||||
"recipients" => (FileDialogRequest::Open { purpose: "recipients".into() }, AfterPick::Recipients),
|
||||
"save" => (FileDialogRequest::Save { name: self.default_save_name() }, AfterPick::SaveAs(crate::file_dialogs::AfterSave::Nothing)),
|
||||
"save" => (
|
||||
FileDialogRequest::Save { name: self.default_save_name() },
|
||||
AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() },
|
||||
),
|
||||
_ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument),
|
||||
};
|
||||
self.show_file_browser(req, after, false);
|
||||
|
||||
@@ -23,13 +23,19 @@ pub enum FileDialogRequest {
|
||||
/// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`, `font`), as for
|
||||
/// `pick_open`.
|
||||
Open { purpose: String },
|
||||
/// Pick one or more files (Add Font…); the answer lists every one picked.
|
||||
OpenMany { purpose: String },
|
||||
/// Pick a path to save to, given a suggested file name.
|
||||
Save { name: String },
|
||||
}
|
||||
|
||||
/// The host's answer to a [`FileDialogRequest`]: the picked path, `Ok(None)` when the user
|
||||
/// cancelled, or an error when no system dialog could be shown (the app then shows its own).
|
||||
pub type FileDialogAnswer = Result<Option<String>, String>;
|
||||
/// The host's answer to a [`FileDialogRequest`]: the picked paths (one for everything but
|
||||
/// [`FileDialogRequest::OpenMany`]), empty when the user cancelled, or an error when no system
|
||||
/// dialog could be shown (the app then shows its own).
|
||||
pub type FileDialogAnswer = Result<Vec<String>, String>;
|
||||
|
||||
/// Most font files one Add Font pick tries; the rest of a bigger selection is skipped.
|
||||
const MAX_FONTS_PER_PICK: usize = 2000;
|
||||
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
@@ -53,14 +59,14 @@ pub(crate) enum AfterPick {
|
||||
/// Insert the picked picture, or replace the pending Change Picture target.
|
||||
Picture,
|
||||
/// Save the document there, then carry on.
|
||||
SaveAs(AfterSave),
|
||||
SaveAs { after: AfterSave, document: u64 },
|
||||
/// Save a copy there in this format (`pdf`, `docx`, `png`…).
|
||||
Export { ext: String },
|
||||
/// Write the PDF of these print settings there (File › Print › Save these pages as PDF).
|
||||
PrintPdf { settings: Value },
|
||||
/// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334).
|
||||
Recipients,
|
||||
/// Add the picked font file (#582).
|
||||
/// Add every picked font file (#582, #1789).
|
||||
Font,
|
||||
/// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins,
|
||||
/// Organizer (#377).
|
||||
@@ -131,7 +137,9 @@ impl WordApp {
|
||||
return self.poll_file_dialog().map_or(Asked::Pending, Asked::Done);
|
||||
}
|
||||
let picked = match &req {
|
||||
FileDialogRequest::Open { purpose } => self.services.pick_open.as_ref().and_then(|f| f(purpose)),
|
||||
FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => {
|
||||
self.services.pick_open.as_ref().and_then(|f| f(purpose))
|
||||
}
|
||||
FileDialogRequest::Save { name } => self.services.pick_save.as_ref().and_then(|f| f(name)),
|
||||
};
|
||||
Asked::Done(self.finish_pick(after, picked))
|
||||
@@ -144,11 +152,11 @@ impl WordApp {
|
||||
None | Some(Err(TryRecvError::Empty)) => return None,
|
||||
Some(Ok(answer)) => answer,
|
||||
// The host dropped the dialog without an answer: as good as cancelled.
|
||||
Some(Err(TryRecvError::Disconnected)) => Ok(None),
|
||||
Some(Err(TryRecvError::Disconnected)) => Ok(Vec::new()),
|
||||
};
|
||||
let d = self.file_dialog.take()?;
|
||||
match answer {
|
||||
Ok(picked) => Some(self.finish_pick(d.after, picked)),
|
||||
Ok(picked) => Some(self.finish_picks(d.after, picked)),
|
||||
Err(reason) => {
|
||||
log::warn!("no system file dialog ({reason}); showing the built-in file browser");
|
||||
self.dialog_unavailable(d.req, d.after)
|
||||
@@ -178,6 +186,53 @@ impl WordApp {
|
||||
Some(self.finish_pick(after, None))
|
||||
}
|
||||
|
||||
/// Do what the dialog was for with the picked paths (none: the user cancelled). Only Add Font
|
||||
/// takes more than one; the others use the first.
|
||||
fn finish_picks(&mut self, after: AfterPick, picked: Vec<String>) -> Result<Value, String> {
|
||||
match after {
|
||||
AfterPick::Font if !picked.is_empty() => self.add_fonts(picked),
|
||||
after => self.finish_pick(after, picked.into_iter().next()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add each picked font file, going on past the ones that fail, and say how it went in one
|
||||
/// status line (#1789). One file keeps `font.add`'s own result and message.
|
||||
fn add_fonts(&mut self, mut paths: Vec<String>) -> Result<Value, String> {
|
||||
if paths.len() == 1 {
|
||||
return self.run("font.add", json!({"path": paths.remove(0)}));
|
||||
}
|
||||
let skipped = paths.len().saturating_sub(MAX_FONTS_PER_PICK);
|
||||
paths.truncate(MAX_FONTS_PER_PICK);
|
||||
let (mut added, mut failed) = (0usize, Vec::new());
|
||||
for path in paths {
|
||||
match self.run("font.add", json!({"path": path})) {
|
||||
Ok(_) => added += 1,
|
||||
Err(e) => {
|
||||
log::warn!("couldn't add font {path}: {e}");
|
||||
let name = std::path::Path::new(&path).file_name().map_or(path.clone(), |n| n.to_string_lossy().into_owned());
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = added.to_string();
|
||||
let mut msg = if added == 1 { tl!("Added 1 font").to_string() } else { crate::i18n::fmt(tl!("Added {n} fonts"), &[("n", &n)]) };
|
||||
if !failed.is_empty() {
|
||||
let count = failed.len().to_string();
|
||||
let mut names = failed.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
|
||||
if failed.len() > 3 {
|
||||
let more = (failed.len() - 3).to_string();
|
||||
names = crate::i18n::fmt(tl!("{names} and {n} more"), &[("names", &names), ("n", &more)]);
|
||||
}
|
||||
msg = crate::i18n::fmt(tl!("{added}; {count} couldn't be read: {names}"), &[("added", &msg), ("count", &count), ("names", &names)]);
|
||||
}
|
||||
if skipped > 0 {
|
||||
let (n, max) = (skipped.to_string(), MAX_FONTS_PER_PICK.to_string());
|
||||
msg = crate::i18n::fmt(tl!("{added}; {n} skipped (limit {max})"), &[("added", &msg), ("n", &n), ("max", &max)]);
|
||||
}
|
||||
self.status(msg);
|
||||
Ok(json!({"added": added, "failed": failed, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// Do what the dialog was for with the picked path (`None`: the user cancelled).
|
||||
pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option<String>) -> Result<Value, String> {
|
||||
match after {
|
||||
@@ -196,7 +251,7 @@ impl WordApp {
|
||||
}
|
||||
AfterPick::Font => {
|
||||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
self.run("font.add", json!({"path": path}))
|
||||
self.add_fonts(vec![path])
|
||||
}
|
||||
AfterPick::ForDialog => {
|
||||
let picked = picked.filter(|p| !p.is_empty());
|
||||
@@ -237,7 +292,10 @@ impl WordApp {
|
||||
}
|
||||
r
|
||||
}
|
||||
AfterPick::SaveAs(after) => {
|
||||
AfterPick::SaveAs { after, document } => {
|
||||
if document != self.session.document_id() {
|
||||
return Err("the document for this save has been replaced".into());
|
||||
}
|
||||
// The save's error, kept for the Save Changes prompt to explain (#1445).
|
||||
let result = picked.map(|path| self.run("file.save", json!({"path": path})));
|
||||
let saved = matches!(&result, Some(Ok(v)) if v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some());
|
||||
@@ -275,7 +333,7 @@ impl WordApp {
|
||||
/// Ask where to save, save there, then do `after`.
|
||||
pub(crate) fn save_as(&mut self, after: AfterSave) -> Asked {
|
||||
let name = self.default_save_name();
|
||||
self.ask_file(FileDialogRequest::Save { name }, AfterPick::SaveAs(after))
|
||||
self.ask_file(FileDialogRequest::Save { name }, AfterPick::SaveAs { after, document: self.session.document_id() })
|
||||
}
|
||||
|
||||
/// File › Export: ask where to save a copy in this format, then write it.
|
||||
@@ -315,7 +373,7 @@ impl WordApp {
|
||||
f("font");
|
||||
return;
|
||||
}
|
||||
let _ = self.ask_file(FileDialogRequest::Open { purpose: "font".into() }, AfterPick::Font);
|
||||
let _ = self.ask_file(FileDialogRequest::OpenMany { purpose: "font".into() }, AfterPick::Font);
|
||||
}
|
||||
|
||||
/// Request loading local system installed fonts (web: queryLocalFonts).
|
||||
@@ -340,6 +398,220 @@ mod tests {
|
||||
|
||||
const UNSAVED: &str = "My unsaved novel chapter";
|
||||
|
||||
/// Original Word 97 compound file: one compressed text piece, built from MS-DOC's FIB/Clx.
|
||||
fn legacy_word_bytes() -> Vec<u8> {
|
||||
use std::io::{Cursor, Write};
|
||||
let text = b"Legacy contract\r";
|
||||
let mut word = vec![0u8; 0x600];
|
||||
for (at, value) in [(0, 0xA5ECu16), (2, 0x00C1), (0x0A, 0x1000), (0x0C, 0x00BF), (0x20, 14), (0x3E, 22), (0x98, 0x005D)] {
|
||||
word[at..at + 2].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
word[0x40..0x44].copy_from_slice(&0x1000u32.to_le_bytes());
|
||||
word[0x4C..0x50].copy_from_slice(&(text.len() as u32).to_le_bytes());
|
||||
let mut clx = vec![2];
|
||||
clx.extend_from_slice(&16u32.to_le_bytes());
|
||||
clx.extend_from_slice(&0u32.to_le_bytes());
|
||||
clx.extend_from_slice(&(text.len() as u32).to_le_bytes());
|
||||
clx.extend_from_slice(&[0, 0]);
|
||||
clx.extend_from_slice(&(0x4000_0000u32 | (2 * 0x600)).to_le_bytes());
|
||||
clx.extend_from_slice(&[0, 0]);
|
||||
let pair = 0x9A + 33 * 8;
|
||||
word[pair..pair + 4].copy_from_slice(&0x40u32.to_le_bytes());
|
||||
word[pair + 4..pair + 8].copy_from_slice(&(clx.len() as u32).to_le_bytes());
|
||||
word.extend_from_slice(text);
|
||||
let mut table = vec![0; 0x40];
|
||||
table.extend_from_slice(&clx);
|
||||
let mut file = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
|
||||
for (name, data) in [("/WordDocument", word), ("/0Table", table)] {
|
||||
file.create_stream(name).unwrap().write_all(&data).unwrap();
|
||||
}
|
||||
file.into_inner().into_inner()
|
||||
}
|
||||
|
||||
/// #2445: Save on an imported binary Word file asks for DOCX, preserving the import on Cancel.
|
||||
#[test]
|
||||
fn imported_word_save_asks_for_docx_and_preserves_the_original() {
|
||||
for ext in ["doc", "DOT", "DOC", "dot"] {
|
||||
let dir = scratch(&format!("imported-{ext}"));
|
||||
let original = dir.join(format!("contract.{ext}"));
|
||||
let bytes = legacy_word_bytes();
|
||||
std::fs::write(&original, &bytes).unwrap();
|
||||
let (mut a, shown) = async_app();
|
||||
a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap();
|
||||
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Legacy contract"));
|
||||
a.run("text.insert", json!({"text": "Revised "})).unwrap();
|
||||
let draft = a.session.doc.clone();
|
||||
let revision = a.session.rev();
|
||||
let result = a.run("file.save", json!({})).unwrap();
|
||||
assert_eq!(result["pending"], "saveAs");
|
||||
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() });
|
||||
answer(&shown, None);
|
||||
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], false);
|
||||
assert_eq!(a.session.doc, draft);
|
||||
assert_eq!(a.session.rev(), revision);
|
||||
assert!(a.session.dirty);
|
||||
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
|
||||
assert_eq!(std::fs::read(&original).unwrap(), bytes);
|
||||
|
||||
a.run("file.save", json!({})).unwrap();
|
||||
let saved = dir.join("revised.docx");
|
||||
answer(&shown, Some(&saved.to_string_lossy()));
|
||||
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], true);
|
||||
assert!(!a.session.dirty);
|
||||
assert_eq!(a.session.path.as_deref(), Some(saved.as_path()));
|
||||
assert!(saved_text(&saved).contains("Revised Legacy contract"));
|
||||
assert_eq!(std::fs::read(&original).unwrap(), bytes);
|
||||
// The new supported path saves directly, without another chooser.
|
||||
a.run("text.insert", json!({"text": "Again "})).unwrap();
|
||||
assert_eq!(a.run("file.save", json!({})).unwrap()["saved"], true);
|
||||
assert!(shown.borrow().is_empty());
|
||||
assert!(saved_text(&saved).contains("Again "));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_word_close_waits_for_save_as_and_keeps_failed_or_cancelled_drafts() {
|
||||
let dir = scratch("imported-close");
|
||||
let original = dir.join("contract.doc");
|
||||
let bytes = legacy_word_bytes();
|
||||
std::fs::write(&original, &bytes).unwrap();
|
||||
let (mut a, shown) = async_app();
|
||||
a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap();
|
||||
a.run("text.insert", json!({"text": "Revised "})).unwrap();
|
||||
let draft = a.session.doc.clone();
|
||||
|
||||
a.run("file.close", json!({})).unwrap();
|
||||
assert_eq!(a.run("ui.saveChanges", json!({"answer": "save"})).unwrap()["pending"], "saveAs");
|
||||
assert!(a.has_document() && !a.quit_requested);
|
||||
answer(&shown, Some(&dir.join("missing").join("contract.docx").to_string_lossy()));
|
||||
let error = a.poll_file_dialog().unwrap().unwrap_err();
|
||||
assert!(error.contains("still open"), "{error}");
|
||||
assert!(matches!(a.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })));
|
||||
assert!(a.has_document() && a.session.dirty && !a.quit_requested);
|
||||
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
|
||||
assert_eq!(std::fs::read(&original).unwrap(), bytes);
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), draft.plain_text(wordcraft_doc::StoryRef::Body));
|
||||
|
||||
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
|
||||
answer(&shown, None);
|
||||
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], false);
|
||||
assert!(a.has_document() && a.session.dirty && !a.quit_requested);
|
||||
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
|
||||
|
||||
a.run("file.close", json!({})).unwrap();
|
||||
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
|
||||
let saved = dir.join("contract.docx");
|
||||
answer(&shown, Some(&saved.to_string_lossy()));
|
||||
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["done"], true);
|
||||
// File › Close keeps the window (#1712): the document closes once the DOCX save succeeds.
|
||||
assert!(!a.has_document() && !a.quit_requested, "Close runs only after the DOCX save succeeds");
|
||||
assert!(saved_text(&saved).contains("Revised Legacy contract"));
|
||||
assert_eq!(std::fs::read(&original).unwrap(), bytes);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A late Save As answer belongs to the imported document that opened the picker.
|
||||
#[test]
|
||||
fn imported_word_stale_save_as_never_writes_the_replacement_document() {
|
||||
for close in [false, true] {
|
||||
let dir = scratch(if close { "imported-stale-close" } else { "imported-stale-save" });
|
||||
let original = dir.join("contract.doc");
|
||||
let bytes = legacy_word_bytes();
|
||||
std::fs::write(&original, &bytes).unwrap();
|
||||
let destination = dir.join("existing.docx");
|
||||
let sentinel = b"Existing destination must survive";
|
||||
std::fs::write(&destination, sentinel).unwrap();
|
||||
let (mut a, shown) = async_app();
|
||||
a.run("file.open", json!({"path": original})).unwrap();
|
||||
typed(&mut a);
|
||||
if close {
|
||||
a.run("file.close", json!({})).unwrap();
|
||||
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
|
||||
} else {
|
||||
a.run("file.save", json!({})).unwrap();
|
||||
}
|
||||
assert!(a.file_dialog_open());
|
||||
let previous = a.session.document_id();
|
||||
// Automation bypasses the user prompt and can replace a document during a picker.
|
||||
a.execute("file.new", json!({})).unwrap();
|
||||
typed(&mut a);
|
||||
assert_ne!(previous, a.session.document_id());
|
||||
let draft = a.session.doc.clone();
|
||||
let revision = a.session.rev();
|
||||
answer(&shown, Some(&destination.to_string_lossy()));
|
||||
let _ = a.poll_file_dialog().unwrap();
|
||||
assert!(std::fs::read(&destination).unwrap() == sentinel, "stale picker overwrote the destination");
|
||||
assert_eq!(std::fs::read(&original).unwrap(), bytes);
|
||||
assert_eq!(a.session.doc, draft);
|
||||
assert_eq!(a.session.rev(), revision);
|
||||
assert!(a.session.dirty && a.session.path.is_none() && !a.quit_requested);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_word_programmatic_and_explicit_saves_never_open_dialogs() {
|
||||
let dir = scratch("imported-explicit");
|
||||
let original = dir.join("contract.doc");
|
||||
let bytes = legacy_word_bytes();
|
||||
std::fs::write(&original, &bytes).unwrap();
|
||||
let (mut a, shown) = async_app();
|
||||
a.run("file.open", json!({"path": original.to_string_lossy()})).unwrap();
|
||||
a.run("text.insert", json!({"text": "Revised "})).unwrap();
|
||||
let error = a.execute("file.save", json!({})).unwrap_err();
|
||||
assert!(error.contains("can't save as `.doc`"), "{error}");
|
||||
let error = a.run("file.save", json!({"path": original.to_string_lossy()})).unwrap_err();
|
||||
assert!(error.contains("can't save as `.doc`"), "{error}");
|
||||
assert!(shown.borrow().is_empty());
|
||||
assert!(a.session.dirty);
|
||||
assert_eq!(a.session.path.as_deref(), Some(original.as_path()));
|
||||
assert_eq!(std::fs::read(&original).unwrap(), bytes);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_word_save_controls_ask_for_docx() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let dir = scratch("imported-entry-points");
|
||||
let original = dir.join("contract.doc");
|
||||
let bytes = legacy_word_bytes();
|
||||
std::fs::write(&original, &bytes).unwrap();
|
||||
let (mut a, shown) = async_app();
|
||||
a.run("ui.language", json!({"value": "en"})).unwrap();
|
||||
a.run("file.open", json!({"path": original})).unwrap();
|
||||
typed(&mut a);
|
||||
a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Save").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().file_dialog_open(), "the Save button asks for a supported target");
|
||||
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() });
|
||||
answer(&shown, None);
|
||||
h.run_steps(3);
|
||||
|
||||
h.state_mut().run("ui.backstage", json!({"value": false})).unwrap();
|
||||
h.state_mut().canvas.want_focus = true;
|
||||
h.run_steps(6);
|
||||
assert!(h.state().canvas.focused, "Mod+S starts in the editor");
|
||||
h.event(egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
|
||||
h.run_steps(3);
|
||||
assert!(h.state().file_dialog_open(), "the focused editor's Mod+S asks too");
|
||||
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() });
|
||||
answer(&shown, None);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.dirty);
|
||||
assert_eq!(h.state().session.path.as_deref(), Some(original.as_path()));
|
||||
assert_eq!(std::fs::read(&original).unwrap(), bytes);
|
||||
}
|
||||
|
||||
/// Dialogs the fake host was asked to show, each with the channel its answer goes through.
|
||||
type Shown = Rc<RefCell<Vec<(FileDialogRequest, Sender<FileDialogAnswer>)>>>;
|
||||
|
||||
@@ -360,8 +632,13 @@ mod tests {
|
||||
|
||||
/// Answer the dialog the host is showing (the last one asked for).
|
||||
fn answer(shown: &Shown, picked: Option<&str>) {
|
||||
answer_all(shown, picked.as_slice());
|
||||
}
|
||||
|
||||
/// Answer the dialog with several picked paths (none: cancelled).
|
||||
fn answer_all(shown: &Shown, picked: &[&str]) {
|
||||
let (_, tx) = shown.borrow_mut().pop().unwrap();
|
||||
tx.send(Ok(picked.map(str::to_string))).unwrap();
|
||||
tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap();
|
||||
}
|
||||
|
||||
fn typed(a: &mut WordApp) {
|
||||
@@ -619,4 +896,99 @@ mod tests {
|
||||
a.run("font.loadSystem", json!({})).unwrap();
|
||||
assert_eq!(a.status_msg.as_ref().map(|(s, _)| s.as_str()), Some("All system fonts are already available."));
|
||||
}
|
||||
|
||||
/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it
|
||||
/// is a font the database doesn't have.
|
||||
fn renamed_font(file: &str, original: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), original.len());
|
||||
let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data.get(i..).and_then(|d| d.windows(from.len()).position(|w| w == from)) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn font_families(a: &mut WordApp) -> Vec<String> {
|
||||
let list = a.run("font.list", json!({"all": true})).unwrap();
|
||||
list["families"].as_array().unwrap().iter().filter_map(|f| f.as_str().map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
/// #1789: Add Font asks for several files, and every one picked is added; an unreadable file
|
||||
/// is reported and the rest still go in.
|
||||
#[test]
|
||||
fn add_font_imports_every_selected_file() {
|
||||
const ALPHA: &str = "Mfpick Alpha 1";
|
||||
const BRAVO: &str = "Mfpick Bravo 2";
|
||||
let dir = scratch("fonts");
|
||||
let alpha = dir.join("MfpickAlpha1-Regular.ttf");
|
||||
let junk = dir.join("Junk.ttf");
|
||||
let bravo = dir.join("MfpickBravo2-Regular.ttf");
|
||||
std::fs::write(&alpha, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", ALPHA)).unwrap();
|
||||
std::fs::write(&junk, b"this is not a font").unwrap();
|
||||
std::fs::write(&bravo, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", BRAVO)).unwrap();
|
||||
|
||||
let (mut a, shown) = async_app();
|
||||
a.pick_font();
|
||||
assert_eq!(shown.borrow()[0].0, FileDialogRequest::OpenMany { purpose: "font".into() }, "Add Font is multi-select");
|
||||
let paths: Vec<String> = [&alpha, &junk, &bravo].iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
answer_all(&shown, &paths.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let r = a.poll_file_dialog().unwrap().unwrap();
|
||||
assert_eq!(r["added"], 2);
|
||||
assert_eq!(r["failed"].as_array().map(Vec::len), Some(1));
|
||||
let families = font_families(&mut a);
|
||||
assert!(families.iter().any(|f| f == ALPHA) && families.iter().any(|f| f == BRAVO), "both valid fonts were added");
|
||||
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
|
||||
assert_eq!(msg, "Added 2 fonts; 1 couldn't be read: Junk.ttf");
|
||||
for f in [ALPHA, BRAVO] {
|
||||
a.run("font.remove", json!({"family": f})).unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Only Add Font is multi-select; a cancelled pick adds nothing.
|
||||
#[test]
|
||||
fn only_add_font_asks_for_several_files() {
|
||||
let (mut a, shown) = async_app();
|
||||
a.open_dialog();
|
||||
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() });
|
||||
answer(&shown, None);
|
||||
a.poll_file_dialog();
|
||||
a.pick_picture();
|
||||
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "picture".into() });
|
||||
answer(&shown, None);
|
||||
a.poll_file_dialog();
|
||||
a.pick_font();
|
||||
assert!(matches!(shown.borrow()[0].0, FileDialogRequest::OpenMany { .. }));
|
||||
answer_all(&shown, &[]);
|
||||
assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true}));
|
||||
assert!(!a.file_dialog_open());
|
||||
}
|
||||
|
||||
/// A hostile selection is bounded: only the first MAX_FONTS_PER_PICK are tried.
|
||||
#[test]
|
||||
fn a_huge_font_selection_is_capped() {
|
||||
let (mut a, _) = async_app();
|
||||
let paths: Vec<String> = (0..MAX_FONTS_PER_PICK + 50).map(|i| format!("/nonexistent/f{i}.ttf")).collect();
|
||||
let r = a.add_fonts(paths).unwrap();
|
||||
assert_eq!(r["added"], 0);
|
||||
assert_eq!(r["failed"].as_array().map(Vec::len), Some(MAX_FONTS_PER_PICK));
|
||||
assert_eq!(r["skipped"], 50);
|
||||
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
|
||||
assert_eq!(msg, "Added 0 fonts; 2000 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 1997 more; 50 skipped (limit 2000)");
|
||||
}
|
||||
|
||||
/// Past three failures the status line names three and counts the rest.
|
||||
#[test]
|
||||
fn many_failed_fonts_end_with_a_count() {
|
||||
let (mut a, _) = async_app();
|
||||
let paths: Vec<String> = (0..5).map(|i| format!("/nonexistent/f{i}.ttf")).collect();
|
||||
a.add_fonts(paths).unwrap();
|
||||
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
|
||||
assert_eq!(msg, "Added 0 fonts; 5 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 2 more");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2645,3 +2645,9 @@
|
||||
All Fonts Всички шрифтове
|
||||
Spike Натрупване
|
||||
Insert Spike Вмъкване на натрупаното
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Добавен 1 шрифт
|
||||
Added {n} fonts Добавени шрифтове: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; не можаха да се прочетат ({count}): {names}
|
||||
{names} and {n} more {names} и още {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; пропуснати: {n} (ограничение {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2648,3 +2648,9 @@ preview Title Ukázka
|
||||
All Fonts Všechna písma
|
||||
Spike Sběrač
|
||||
Insert Spike Vložit ze sběrače
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Přidáno 1 písmo
|
||||
Added {n} fonts Přidaná písma: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; nelze přečíst ({count}): {names}
|
||||
{names} and {n} more {names} a dalších {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; přeskočeno: {n} (limit {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2256,3 +2256,9 @@
|
||||
All Fonts Alle Schriftarten
|
||||
Spike Sammlung
|
||||
Insert Spike Sammlung einfügen
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 Schriftart hinzugefügt
|
||||
Added {n} fonts {n} Schriftarten hinzugefügt
|
||||
{added}; {count} couldn't be read: {names} {added}; {count} nicht lesbar: {names}
|
||||
{names} and {n} more {names} und {n} weitere
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} übersprungen (Limit {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2623,3 +2623,9 @@ preview Title Título
|
||||
All Fonts Todas las fuentes
|
||||
Spike Acumulador
|
||||
Insert Spike Insertar acumulador
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 fuente añadida
|
||||
Added {n} fonts {n} fuentes añadidas
|
||||
{added}; {count} couldn't be read: {names} {added}; no se pudieron leer {count}: {names}
|
||||
{names} and {n} more {names} y {n} más
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} omitidas (límite {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2291,3 +2291,9 @@
|
||||
All Fonts Kõik fondid
|
||||
Spike Koguja
|
||||
Insert Spike Lisa kogujast
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Lisati 1 font
|
||||
Added {n} fonts Lisati {n} fonti
|
||||
{added}; {count} couldn't be read: {names} {added}; {count} ei õnnestunud lugeda: {names}
|
||||
{names} and {n} more {names} ja veel {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} jäeti vahele (piir {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 845 and column 23.
|
@@ -2264,3 +2264,414 @@
|
||||
All Fonts Toutes les polices
|
||||
Spike Pique-notes
|
||||
Insert Spike Insérer le pique-notes
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 police ajoutée
|
||||
Added {n} fonts {n} polices ajoutées
|
||||
{added}; {count} couldn't be read: {names} {added} ; {count} illisibles : {names}
|
||||
{names} and {n} more {names} et {n} autres
|
||||
{added}; {n} skipped (limit {max}) {added} ; {n} ignorées (limite {max})
|
||||
|
||||
# Added 2026-10-16: equation gallery, built-in style names, citation styles, paper sizes,
|
||||
# watermarks and newer dialogs (402 entries), with a native-speaker review of the catalog.
|
||||
1×2 Empty Matrix Matrice vide 1×2
|
||||
1×3 Empty Matrix Matrice vide 1×3
|
||||
2×1 Empty Matrix Matrice vide 2×1
|
||||
2×2 Determinant Déterminant 2×2
|
||||
2×2 Empty Matrix Matrice vide 2×2
|
||||
2×2 Identity Matrix Matrice identité 2×2
|
||||
2×2 Identity Matrix (sparse) Matrice identité 2×2 (creuse)
|
||||
2×2 Matrix in Brackets Matrice 2×2 entre crochets
|
||||
2×2 Matrix in Double Bars Matrice 2×2 entre doubles barres
|
||||
2×2 Matrix in Parentheses Matrice 2×2 entre parenthèses
|
||||
2×3 Empty Matrix Matrice vide 2×3
|
||||
3×1 Empty Matrix Matrice vide 3×1
|
||||
3×2 Empty Matrix Matrice vide 3×2
|
||||
3×3 Empty Matrix Matrice vide 3×3
|
||||
3×3 Identity Matrix Matrice identité 3×3
|
||||
3×3 Identity Matrix (sparse) Matrice identité 3×3 (creuse)
|
||||
A xor B A ou exclusif B
|
||||
APA APA
|
||||
ASAP AU PLUS VITE
|
||||
AaBbCcDd AaBbCcDd
|
||||
Absolute value cases Valeur absolue par cas
|
||||
Accents Accents
|
||||
Acute Accent aigu
|
||||
All - Linear Tout - Linéaire
|
||||
All - Professional Tout - Professionnel
|
||||
Angle Brackets Chevrons
|
||||
Angle Brackets with Separator Chevrons avec séparateur
|
||||
Angle Brackets with Two Separators Chevrons avec deux séparateurs
|
||||
Annotation Reference Marque de commentaire
|
||||
Annotation Subject Objet du commentaire
|
||||
Annotation Text Texte de commentaire
|
||||
B4 (JIS) 10.12" × 14.33" B4 (JIS) 10.12" × 14.33"
|
||||
B5 (JIS) 7.17" × 10.12" B5 (JIS) 7.17" × 10.12"
|
||||
Baseline Dots Points sur la ligne de base
|
||||
Basic Math Mathématiques de base
|
||||
Bayes' rule Théorème de Bayes
|
||||
Binomial Coefficient Coefficient binomial
|
||||
Binomial Coefficient (angle) Coefficient binomial (chevrons)
|
||||
Binomial Coefficient with Angle Brackets Coefficient binomial entre chevrons
|
||||
Binomial expansion of (x+a)ⁿ Développement binomial de (x+a)ⁿ
|
||||
Body text in a short paragraph to show spacing. Corps de texte dans un court paragraphe pour montrer l’espacement.
|
||||
Book Title Titre du livre
|
||||
Boxed Formula Formule encadrée
|
||||
Boxed Formula (example) Formule encadrée (exemple)
|
||||
Boxed Formulas Formules encadrées
|
||||
Braces Accolades
|
||||
Braces with Separator Accolades avec séparateur
|
||||
Brackets Crochets
|
||||
Brackets with Separators Crochets avec séparateurs
|
||||
Breve Brève
|
||||
CONFIDENTIAL CONFIDENTIEL
|
||||
Cases (Three Conditions) Cas (trois conditions)
|
||||
Cases (Two Conditions) Cas (deux conditions)
|
||||
Cases and Stacks Cas et empilements
|
||||
Category Catégorie
|
||||
Ceiling Partie entière supérieure
|
||||
Centered as Group Centré en groupe
|
||||
Change in y over change in x Variation de y sur variation de x
|
||||
Change to Level 1 Passer au niveau 1
|
||||
Change to Level 2 Passer au niveau 2
|
||||
Change to Level 3 Passer au niveau 3
|
||||
Change to Level 4 Passer au niveau 4
|
||||
Change to Level 5 Passer au niveau 5
|
||||
Change to Level 6 Passer au niveau 6
|
||||
Change to Level 7 Passer au niveau 7
|
||||
Change to Level 8 Passer au niveau 8
|
||||
Change to Level 9 Passer au niveau 9
|
||||
Check Caron
|
||||
Chicago Chicago
|
||||
Circle area from its radius Aire du cercle à partir de son rayon
|
||||
Circled Dot Point cerclé
|
||||
Circled Plus Plus cerclé
|
||||
Circled Times Fois cerclé
|
||||
Colon Equal Deux-points égal
|
||||
Common Accent Objects Accents courants
|
||||
Common Brackets Parenthèses et crochets courants
|
||||
Common Fractions Fractions courantes
|
||||
Common Functions Fonctions courantes
|
||||
Common Large Operators Grands opérateurs courants
|
||||
Common Operator Structures Structures d’opérateurs courantes
|
||||
Common Operators Opérateurs courants
|
||||
Common Radicals Radicaux courants
|
||||
Common Subscripts and Superscripts Indices et exposants courants
|
||||
Contour Integral Intégrale curviligne
|
||||
Contour Integral with Limits Intégrale curviligne avec bornes
|
||||
Contour Integral with Stacked Limits Intégrale curviligne avec bornes empilées
|
||||
Contour Integrals Intégrales curvilignes
|
||||
Contributors Contributeurs
|
||||
Convert between built-up and linear form Basculer entre forme développée et forme linéaire
|
||||
Coproduct Coproduit
|
||||
Coproduct with Limits Coproduit avec bornes
|
||||
Coproduct with Lower Limit Coproduit avec borne inférieure
|
||||
Coproduct with Subscript-Superscript Limits Coproduit avec bornes en indice et exposant
|
||||
Cosecant Function Cosécante
|
||||
Cosine 2x Cosinus 2x
|
||||
Cosine Function Cosinus
|
||||
Cotangent Function Cotangente
|
||||
Cube Root Racine cubique
|
||||
Current - Linear Sélection - Linéaire
|
||||
Current - Professional Sélection - Professionnel
|
||||
DO NOT COPY NE PAS COPIER
|
||||
DRAFT BROUILLON
|
||||
Default Paragraph Font Police par défaut
|
||||
Delta Equal To Delta égal à
|
||||
Delta over arrow Delta au-dessus d’une flèche
|
||||
Derivative as a limit Dérivée comme limite
|
||||
Diagonal Dots Points diagonaux
|
||||
Differential Différentielle
|
||||
Differential theta Différentielle thêta
|
||||
Differential x Différentielle x
|
||||
Differential y Différentielle y
|
||||
Differentials Différentielles
|
||||
Dot Point
|
||||
Dots Points
|
||||
Double Bar Double barre
|
||||
Double Dot Double point
|
||||
Double Integral Intégrale double
|
||||
Double Integral with Limits Intégrale double avec bornes
|
||||
Double Integral with Stacked Limits Intégrale double avec bornes empilées
|
||||
Double Left Arrow with Text Above Double flèche gauche avec texte au-dessus
|
||||
Double Left Arrow with Text Below Double flèche gauche avec texte en dessous
|
||||
Double Left Right Arrow with Text Above Double flèche bidirectionnelle avec texte au-dessus
|
||||
Double Left Right Arrow with Text Below Double flèche bidirectionnelle avec texte en dessous
|
||||
Double Right Arrow with Text Above Double flèche droite avec texte au-dessus
|
||||
Double Right Arrow with Text Below Double flèche droite avec texte en dessous
|
||||
Double Square Brackets Doubles crochets
|
||||
Double Vertical Bars Doubles barres verticales
|
||||
Double summation Somme double
|
||||
Emphasis Accentuation
|
||||
Empty Matrices Matrices vides
|
||||
Endnote Reference Appel de note de fin
|
||||
Endnote Text Note de fin
|
||||
Enter bookmark name: Nom du signet :
|
||||
Envelope #10 4.12" × 9.50" Enveloppe nº 10 4.12" × 9.50"
|
||||
Envelope Address Adresse destinataire
|
||||
Envelope DL 4.33" × 8.66" Enveloppe DL 4.33" × 8.66"
|
||||
Envelope Return Adresse expéditeur
|
||||
Equal Equal Égal égal
|
||||
Equal by Definition Égal par définition
|
||||
Euler's identity Identité d’Euler
|
||||
Executive 7.25" × 10.50" Executive 7.25" × 10.50"
|
||||
Exponential Exponentielle
|
||||
Exponential series Série exponentielle
|
||||
Find “{text}” in the document Rechercher « {text} » dans le document
|
||||
Floor Partie entière inférieure
|
||||
Footnote Reference Appel de note de bas de page
|
||||
Footnote Text Note de bas de page
|
||||
For which item: Pour quel élément :
|
||||
Functions Fonctions
|
||||
GB/T 7714 GB/T 7714
|
||||
GOST (name sort) GOST (tri par nom)
|
||||
GOST (title sort) GOST (tri par titre)
|
||||
Geometry Géométrie
|
||||
Good afternoon Bon après-midi
|
||||
Good evening Bonsoir
|
||||
Good morning Bonjour
|
||||
Grave Accent grave
|
||||
Greek Letters Lettres grecques
|
||||
Grid Table 1 Light Grille claire 1
|
||||
Grid Table 4 – Blue Grille 4 – Bleu
|
||||
Grid Table 4 – Green Grille 4 – Vert
|
||||
Grid Table 4 – Orange Grille 4 – Orange
|
||||
Grid Table 4 – Purple Grille 4 – Violet
|
||||
Grouping Character Above Caractère de regroupement au-dessus
|
||||
Grouping Character Below Caractère de regroupement en dessous
|
||||
Harvard (Anglia) Harvard (Anglia)
|
||||
Hat Accent circonflexe
|
||||
Heading Titre
|
||||
Heading text Texte de titre
|
||||
Hyperbolic Cosecant Function Cosécante hyperbolique
|
||||
Hyperbolic Cosine Function Cosinus hyperbolique
|
||||
Hyperbolic Cotangent Function Cotangente hyperbolique
|
||||
Hyperbolic Functions Fonctions hyperboliques
|
||||
Hyperbolic Secant Function Sécante hyperbolique
|
||||
Hyperbolic Sine Function Sinus hyperbolique
|
||||
Hyperbolic Tangent Function Tangente hyperbolique
|
||||
Hyperlink Lien hypertexte
|
||||
Hypotenuse Hypoténuse
|
||||
IEEE IEEE
|
||||
ISO 690 (author-date) ISO 690 (auteur-date)
|
||||
ISO 690 (numerical) ISO 690 (numérique)
|
||||
Identity Matrices Matrices identité
|
||||
Index Heading Titre d’index
|
||||
Index {n} Index {n}
|
||||
Insert reference to: Insérer une référence à :
|
||||
Integral of e^(−x²) Intégrale de e^(−x²)
|
||||
Integral with Limits Intégrale avec bornes
|
||||
Integral with Stacked Limits Intégrale avec bornes empilées
|
||||
Integrals Intégrales
|
||||
Intense Emphasis Accentuation intense
|
||||
Intense Quote Citation intense
|
||||
Intense Reference Référence intense
|
||||
Intersection Intersection
|
||||
Intersection with Limits Intersection avec bornes
|
||||
Intersection with Lower Limit Intersection avec borne inférieure
|
||||
Intersection with Subscript-Superscript Limits Intersection avec bornes en indice et exposant
|
||||
Inverse Cosecant Function Arc cosécante
|
||||
Inverse Cosine Function Arc cosinus
|
||||
Inverse Cotangent Function Arc cotangente
|
||||
Inverse Functions Fonctions réciproques
|
||||
Inverse Hyperbolic Cosecant Function Argument cosécante hyperbolique
|
||||
Inverse Hyperbolic Cosine Function Argument cosinus hyperbolique
|
||||
Inverse Hyperbolic Cotangent Function Argument cotangente hyperbolique
|
||||
Inverse Hyperbolic Functions Fonctions hyperboliques réciproques
|
||||
Inverse Hyperbolic Secant Function Argument sécante hyperbolique
|
||||
Inverse Hyperbolic Sine Function Argument sinus hyperbolique
|
||||
Inverse Hyperbolic Tangent Function Argument tangente hyperbolique
|
||||
Inverse Secant Function Arc sécante
|
||||
Inverse Sine Function Arc sinus
|
||||
Inverse Tangent Function Arc tangente
|
||||
Justification of a display equation Alignement d’une équation hors texte
|
||||
Keywords Mots-clés
|
||||
Large Grand
|
||||
Left Arrow with Text Above Flèche gauche avec texte au-dessus
|
||||
Left Arrow with Text Below Flèche gauche avec texte en dessous
|
||||
Left Bracket Twice Double crochet gauche
|
||||
Left Right Arrow Above Flèche bidirectionnelle au-dessus
|
||||
Left Right Arrow with Text Above Flèche bidirectionnelle avec texte au-dessus
|
||||
Left Right Arrow with Text Below Flèche bidirectionnelle avec texte en dessous
|
||||
Left Subscript-Superscript Indice-exposant à gauche
|
||||
Leftwards Arrow Above Flèche vers la gauche au-dessus
|
||||
Leftwards Harpoon Above Harpon vers la gauche au-dessus
|
||||
Legal 8.50" × 14.00" Legal 8.50" × 14.00"
|
||||
Letter 8.50" × 11.00" Letter 8.50" × 11.00"
|
||||
Letter-Like Symbols Symboles de type lettre
|
||||
Level 1 Niveau 1
|
||||
Level 2 Niveau 2
|
||||
Level 3 Niveau 3
|
||||
Level 4 Niveau 4
|
||||
Level 5 Niveau 5
|
||||
Level 6 Niveau 6
|
||||
Level 7 Niveau 7
|
||||
Level 8 Niveau 8
|
||||
Level 9 Niveau 9
|
||||
Limit Limite
|
||||
Limit Example Exemple de limite
|
||||
Limit and Limite et
|
||||
Line Number Numéro de ligne
|
||||
Linear Fraction Fraction linéaire
|
||||
List Paragraph Paragraphe de liste
|
||||
List Table 3 – Blue Liste 3 – Bleu
|
||||
List Table 3 – Green Liste 3 – Vert
|
||||
List Table 3 – Orange Liste 3 – Orange
|
||||
List Table 3 – Purple Liste 3 – Violet
|
||||
Log Log
|
||||
Logarithm Logarithme
|
||||
Logarithm with Base Logarithme de base donnée
|
||||
Logical AND ET logique
|
||||
Logical AND with Limits ET logique avec bornes
|
||||
Logical OR OU logique
|
||||
Logical OR with Limits OU logique avec bornes
|
||||
MLA MLA
|
||||
Matrices with Brackets Matrices entre crochets
|
||||
Maximum Maximum
|
||||
Maximum Example Exemple de maximum
|
||||
Measured By Mesuré par
|
||||
Midline Dots Points médians
|
||||
Minimum Minimum
|
||||
Minus Equal Moins égal
|
||||
Models Modèles
|
||||
Natural Logarithm Logarithme népérien
|
||||
Negated Relations Relations niées
|
||||
No Spacing Sans interligne
|
||||
Normal Normal
|
||||
Normal Indent Retrait normal
|
||||
Normal Table Tableau normal
|
||||
Normal probability density Densité de la loi normale
|
||||
Number the equation; the number sits at the right margin (or type # then the number) Numéroter l’équation ; le numéro se place dans la marge droite (ou tapez # puis le numéro)
|
||||
One half Un demi
|
||||
Operator Structures Structures d’opérateurs
|
||||
Operators Opérateurs
|
||||
Other Large Operators Autres grands opérateurs
|
||||
Outward Brackets Crochets inversés
|
||||
Overbar Barre supérieure
|
||||
Overbars and Underbars Barres supérieures et inférieures
|
||||
Overbrace Accolade supérieure
|
||||
Parentheses Parenthèses
|
||||
Parentheses with Separator Parenthèses avec séparateur
|
||||
Partial Differential Différentielle partielle
|
||||
Pi Over 2 Pi sur 2
|
||||
Plain Table 1 Tableau simple 1
|
||||
Plus Equal Plus égal
|
||||
Power series of (1+x)ⁿ Série entière de (1+x)ⁿ
|
||||
Product Produit
|
||||
Product of A Produit de A
|
||||
Product with Limits Produit avec bornes
|
||||
Product with Lower Limit Produit avec borne inférieure
|
||||
Product with Subscript-Superscript Limits Produit avec bornes en indice et exposant
|
||||
Products and Coproducts Produits et coproduits
|
||||
Quadratic Root Racine du trinôme
|
||||
Quote Citation
|
||||
Radical with Degree Radical avec indice
|
||||
Radicals Radicaux
|
||||
Reference type: Type de référence :
|
||||
Right Arrow with Text Above Flèche droite avec texte au-dessus
|
||||
Right Arrow with Text Below Flèche droite avec texte en dessous
|
||||
Right Bracket Twice Double crochet droit
|
||||
Rightwards Arrow Above Flèche vers la droite au-dessus
|
||||
Rightwards Harpoon Above Harpon vers la droite au-dessus
|
||||
Roots of ax²+bx+c Racines de ax²+bx+c
|
||||
SAMPLE SPÉCIMEN
|
||||
SIST02 SIST02
|
||||
Scripts Indices et exposants
|
||||
Secant Function Sécante
|
||||
Show the equation in the line of text Afficher l’équation dans la ligne de texte
|
||||
Show the equation on a line of its own Afficher l’équation sur sa propre ligne
|
||||
Sides of a right triangle Côtés d’un triangle rectangle
|
||||
Sine Function Sinus
|
||||
Sine and cosine series of f(x) Séries de sinus et cosinus de f(x)
|
||||
Sine theta Sinus thêta
|
||||
Single Brackets Délimiteurs simples
|
||||
Single Left Angle Chevron gauche seul
|
||||
Single Left Bar Barre gauche seule
|
||||
Single Left Brace Accolade gauche seule
|
||||
Single Left Bracket Crochet gauche seul
|
||||
Single Left Parenthesis Parenthèse gauche seule
|
||||
Single Right Brace Accolade droite seule
|
||||
Single Right Bracket Crochet droit seul
|
||||
Single Right Parenthesis Parenthèse droite seule
|
||||
Skewed Fraction Fraction oblique
|
||||
Small change Petite variation
|
||||
Sparse Matrices Matrices creuses
|
||||
Square Root Racine carrée
|
||||
Square Root with Degree Racine carrée avec indice
|
||||
Square Union Union carrée
|
||||
Stack Object Empilement
|
||||
Stack Object (Three) Empilement (trois)
|
||||
Stacked Fraction Fraction empilée
|
||||
Stacked, no bar Empilé, sans barre
|
||||
Statement 5.50" × 8.50" Statement 5.50" × 8.50"
|
||||
Strong Élevé
|
||||
Style: APA Style : APA
|
||||
Subject Objet
|
||||
Subscript-Superscript Indice-exposant
|
||||
Subscripts and Superscripts Indices et exposants
|
||||
Subtitle Sous-titre
|
||||
Subtle Emphasis Accentuation légère
|
||||
Subtle Reference Référence légère
|
||||
Sum of cosines Somme de cosinus
|
||||
Sum or difference of sines Somme ou différence de sinus
|
||||
Summation Somme
|
||||
Summation from 0 to n Somme de 0 à n
|
||||
Summation over binomials Somme de coefficients binomiaux
|
||||
Summation with Limits Somme avec bornes
|
||||
Summation with Lower Limit Somme avec borne inférieure
|
||||
Summation with Subscript Limit Somme avec borne en indice
|
||||
Summation with Subscript-Superscript Limits Somme avec bornes en indice et exposant
|
||||
Summations Sommes
|
||||
Surface Integral Intégrale de surface
|
||||
Surface Integral with Limits Intégrale de surface avec bornes
|
||||
Surface Integral with Stacked Limits Intégrale de surface avec bornes empilées
|
||||
TOA Heading En-tête de table des autorités
|
||||
TOC Heading En-tête de table des matières
|
||||
TOC {n} TM {n}
|
||||
Table Grid Grille du tableau
|
||||
Table of Figures Table des illustrations
|
||||
Tabloid 11.00" × 17.00" Tabloïd 11.00" × 17.00"
|
||||
Tangent Function Tangente
|
||||
Tangent formula Formule de la tangente
|
||||
Text over Colon Equal Texte au-dessus de deux-points égal
|
||||
Text over Equal Texte au-dessus du signe égal
|
||||
This document has no bookmarks. Ce document ne contient aucun signet.
|
||||
Tilde Tilde
|
||||
Trigonometric Functions Fonctions trigonométriques
|
||||
Triple Dot Triple point
|
||||
Triple Integral Intégrale triple
|
||||
Triple Integral with Limits Intégrale triple avec bornes
|
||||
Triple Integral with Stacked Limits Intégrale triple avec bornes empilées
|
||||
Turabian Turabian
|
||||
Type a page number, or +2 / -1. Saisissez un numéro de page, ou +2 / -1.
|
||||
Type equations in LaTeX Saisir les équations en LaTeX
|
||||
Type equations in the linear format (UnicodeMath) Saisir les équations au format linéaire (UnicodeMath)
|
||||
Type normal (non-math) text in the equation Saisir du texte normal (non mathématique) dans l’équation
|
||||
Typing Saisie
|
||||
URGENT URGENT
|
||||
Underbar Barre inférieure
|
||||
Underbrace Accolade inférieure
|
||||
Union Union
|
||||
Union of intersections Union d’intersections
|
||||
Union with Limits Union avec bornes
|
||||
Union with Lower Limit Union avec borne inférieure
|
||||
Union with Plus Union disjointe
|
||||
Union with Subscript-Superscript Limits Union avec bornes en indice et exposant
|
||||
Unions and Intersections Unions et intersections
|
||||
Up Diagonal Dots Points diagonaux montants
|
||||
Vector A Vecteur A
|
||||
Vector AB Vecteur AB
|
||||
Vertical Bars Barres verticales
|
||||
Vertical Dots Points verticaux
|
||||
Volume Integral Intégrale de volume
|
||||
Volume Integral with Limits Intégrale de volume avec bornes
|
||||
Volume Integral with Stacked Limits Intégrale de volume avec bornes empilées
|
||||
Wait for the print job to finish submitting before closing. Attendez la fin de l’envoi du travail d’impression avant de fermer.
|
||||
Wildcards (regex) Caractères génériques (regex)
|
||||
Yields Donne
|
||||
n×n Identity Matrix Matrice identité n×n
|
||||
n×n Matrix Matrice n×n
|
||||
tOGGLE cASE iNVERSER lA cASSE
|
||||
x bar x barre
|
||||
✓ None ✓ Aucun
|
||||
preview Title Titre
|
||||
template Letter Lettre
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2656,3 +2656,9 @@ preview Title Naslov
|
||||
Moved to: Premješteno u:
|
||||
Spike Skupljač
|
||||
Insert Spike Umetni iz skupljača
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Dodan 1 font
|
||||
Added {n} fonts Dodano fontova: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; nije moguće pročitati ({count}): {names}
|
||||
{names} and {n} more {names} i još {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; preskočeno: {n} (ograničenje {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2652,3 +2652,9 @@
|
||||
All Fonts Összes betűtípus
|
||||
Spike Gyűjtő
|
||||
Insert Spike Beszúrás a gyűjtőből
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 betűtípus hozzáadva
|
||||
Added {n} fonts {n} betűtípus hozzáadva
|
||||
{added}; {count} couldn't be read: {names} {added}; {count} nem olvasható: {names}
|
||||
{names} and {n} more {names} és még {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} kihagyva (korlát: {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2665,3 +2665,9 @@ preview Title Esempio
|
||||
All Fonts Tutti i caratteri
|
||||
Spike Raccoglitore
|
||||
Insert Spike Inserisci raccoglitore
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 tipo di carattere aggiunto
|
||||
Added {n} fonts {n} tipi di carattere aggiunti
|
||||
{added}; {count} couldn't be read: {names} {added}; {count} non leggibili: {names}
|
||||
{names} and {n} more {names} e altri {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} saltati (limite {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2257,3 +2257,9 @@
|
||||
All Fonts すべてのフォント
|
||||
Spike スパイク
|
||||
Insert Spike スパイクの挿入
|
||||
# Add Font status (#1789)
|
||||
Added 1 font フォントを 1 件追加しました
|
||||
Added {n} fonts フォントを {n} 件追加しました
|
||||
{added}; {count} couldn't be read: {names} {added}。読み込めなかったもの {count} 件: {names}
|
||||
{names} and {n} more {names} ほか {n} 件
|
||||
{added}; {n} skipped (limit {max}) {added}。{n} 件をスキップしました (上限 {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 923 and column 23.
|
@@ -2510,3 +2510,9 @@ preview Title 제목
|
||||
All Fonts 모든 글꼴
|
||||
Spike 스파이크
|
||||
Insert Spike 스파이크 삽입
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 글꼴 1개를 추가했습니다
|
||||
Added {n} fonts 글꼴 {n}개를 추가했습니다
|
||||
{added}; {count} couldn't be read: {names} {added}; 읽을 수 없는 파일 {count}개: {names}
|
||||
{names} and {n} more {names} 외 {n}개
|
||||
{added}; {n} skipped (limit {max}) {added}; {n}개 건너뜀 (한도 {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 929 and column 23.
|
@@ -2661,3 +2661,9 @@ preview Title Tytuł
|
||||
All Fonts Wszystkie czcionki
|
||||
Spike Zbieracz
|
||||
Insert Spike Wstaw ze zbieracza
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Dodano 1 czcionkę
|
||||
Added {n} fonts Dodano czcionek: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; nie udało się odczytać ({count}): {names}
|
||||
{names} and {n} more {names} i {n} więcej
|
||||
{added}; {n} skipped (limit {max}) {added}; pominięto: {n} (limit {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2269,3 +2269,9 @@
|
||||
All Fonts Todas as fontes
|
||||
Spike Coletor
|
||||
Insert Spike Inserir coletor
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 fonte adicionada
|
||||
Added {n} fonts {n} fontes adicionadas
|
||||
{added}; {count} couldn't be read: {names} {added}; {count} não puderam ser lidas: {names}
|
||||
{names} and {n} more {names} e mais {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} ignoradas (limite de {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2654,3 +2654,9 @@ preview Title Пример
|
||||
All Fonts Все шрифты
|
||||
Spike Копилка
|
||||
Insert Spike Вставить из копилки
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Добавлен 1 шрифт
|
||||
Added {n} fonts Добавлено шрифтов: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; не удалось прочитать ({count}): {names}
|
||||
{names} and {n} more {names} и ещё {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; пропущено: {n} (лимит {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2267,3 +2267,9 @@
|
||||
All Fonts Svi fontovi
|
||||
Spike Sakupljač
|
||||
Insert Spike Umetni iz sakupljača
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Dodat je 1 font
|
||||
Added {n} fonts Dodato fontova: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; nije moguće pročitati ({count}): {names}
|
||||
{names} and {n} more {names} i još {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; preskočeno: {n} (ograničenje {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -2271,3 +2271,9 @@
|
||||
All Fonts Сви фонтови
|
||||
Spike Сакупљач
|
||||
Insert Spike Уметни из сакупљача
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Додат је 1 фонт
|
||||
Added {n} fonts Додато фонтова: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; није могуће прочитати ({count}): {names}
|
||||
{names} and {n} more {names} и још {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; прескочено: {n} (ограничење {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -2671,3 +2671,9 @@ preview Title Titel
|
||||
Moved to: Flyttat till:
|
||||
Spike Samlare
|
||||
Insert Spike Infoga samlare
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 teckensnitt har lagts till
|
||||
Added {n} fonts {n} teckensnitt har lagts till
|
||||
{added}; {count} couldn't be read: {names} {added}; {count} kunde inte läsas: {names}
|
||||
{names} and {n} more {names} och {n} till
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} hoppades över (gräns {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -990,8 +990,9 @@ fn polish_covers_every_key_of_every_other_catalog() {
|
||||
// Italian and Hungarian still carry "Style: APA", which the interface no longer shows;
|
||||
// Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line
|
||||
// pieces of gallery labels. Polish matches every other catalog, including the built-in style
|
||||
// names, citation styles and equation gallery.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv" | "hr")) {
|
||||
// names, citation styles and equation gallery. French carries newer strings (paper sizes,
|
||||
// bookmarks, printing) the Polish catalog hasn't caught up with yet.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg" | "vi" | "sv" | "hr" | "fr")) {
|
||||
let mut missing: Vec<String> = keys(other.0.source).difference(&polish).cloned().collect();
|
||||
missing.sort();
|
||||
assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code());
|
||||
|
||||
@@ -2653,3 +2653,9 @@ preview Title Örnek
|
||||
All Fonts Tüm Yazı Tipleri
|
||||
Spike Biriktirici
|
||||
Insert Spike Biriktiriciyi Ekle
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 1 yazı tipi eklendi
|
||||
Added {n} fonts {n} yazı tipi eklendi
|
||||
{added}; {count} couldn't be read: {names} {added}; {count} okunamadı: {names}
|
||||
{names} and {n} more {names} ve {n} tane daha
|
||||
{added}; {n} skipped (limit {max}) {added}; {n} atlandı (sınır {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 797 and column 23.
|
@@ -2269,3 +2269,9 @@
|
||||
All Fonts Усі шрифти
|
||||
Spike Накопичувач
|
||||
Insert Spike Вставити з накопичувача
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Додано 1 шрифт
|
||||
Added {n} fonts Додано шрифтів: {n}
|
||||
{added}; {count} couldn't be read: {names} {added}; не вдалося прочитати ({count}): {names}
|
||||
{names} and {n} more {names} та ще {n}
|
||||
{added}; {n} skipped (limit {max}) {added}; пропущено: {n} (ліміт {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -2696,3 +2696,9 @@ template Letter Thư
|
||||
All Fonts Tất cả phông chữ
|
||||
Spike Bộ gom
|
||||
Insert Spike Chèn bộ gom
|
||||
# Add Font status (#1789)
|
||||
Added 1 font Đã thêm 1 phông chữ
|
||||
Added {n} fonts Đã thêm {n} phông chữ
|
||||
{added}; {count} couldn't be read: {names} {added}; không đọc được {count}: {names}
|
||||
{names} and {n} more {names} và {n} tệp nữa
|
||||
{added}; {n} skipped (limit {max}) {added}; đã bỏ qua {n} (giới hạn {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2269,3 +2269,9 @@
|
||||
All Fonts 所有字体
|
||||
Spike 图文场
|
||||
Insert Spike 插入图文场
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 已添加 1 个字体
|
||||
Added {n} fonts 已添加 {n} 个字体
|
||||
{added}; {count} couldn't be read: {names} {added};{count} 个无法读取:{names}
|
||||
{names} and {n} more {names} 等另外 {n} 个
|
||||
{added}; {n} skipped (limit {max}) {added};已跳过 {n} 个(上限 {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2258,3 +2258,9 @@
|
||||
All Fonts 所有字型
|
||||
Spike 收集區
|
||||
Insert Spike 插入收集區
|
||||
# Add Font status (#1789)
|
||||
Added 1 font 已新增 1 個字型
|
||||
Added {n} fonts 已新增 {n} 個字型
|
||||
{added}; {count} couldn't be read: {names} {added};{count} 個無法讀取:{names}
|
||||
{names} and {n} more {names} 等另外 {n} 個
|
||||
{added}; {n} skipped (limit {max}) {added};已略過 {n} 個(上限 {max})
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 924 and column 23.
|
+212
-5
@@ -1101,9 +1101,9 @@ impl WordApp {
|
||||
let go = match choice {
|
||||
SaveChoice::Cancel => false,
|
||||
SaveChoice::DontSave => true,
|
||||
// A new document: ask where through Save As; the command runs once that has saved,
|
||||
// which with the desktop's non-blocking dialog is on a later frame (#94).
|
||||
SaveChoice::Save if self.session.path.is_none() && self.services.download.is_none() => {
|
||||
// A new document or an import-only Word file: ask where through Save As. The command
|
||||
// runs once that has saved, on a later frame with the non-blocking dialog (#94).
|
||||
SaveChoice::Save if (self.session.path.is_none() || self.legacy_word_path()) && self.services.download.is_none() => {
|
||||
return match self.save_as(file_dialogs::AfterSave::Continue { then, params, document }) {
|
||||
file_dialogs::Asked::Done(r) => r,
|
||||
file_dialogs::Asked::Pending => Ok(json!({"pending": "saveAs"})),
|
||||
@@ -1129,9 +1129,23 @@ impl WordApp {
|
||||
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
|
||||
}
|
||||
|
||||
/// [`WordApp::execute`] for something the user did: a document that turns out to be
|
||||
/// password-protected asks for the password (and opens with it) instead of failing (#55).
|
||||
/// User commands can ask for a supported Save As target or an opening password (#55).
|
||||
/// Scripts and agents use [`WordApp::execute`], which never opens these dialogs.
|
||||
pub(crate) fn execute_user(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
// Legacy Word files are import-only (#2445). Only a user's implicit Save asks for
|
||||
// a supported target; scripts and explicitly supplied paths keep their normal errors.
|
||||
if id == "file.save"
|
||||
&& params.get("path").and_then(Value::as_str).is_none()
|
||||
&& self.has_document()
|
||||
&& self.legacy_word_path()
|
||||
&& self.services.download.is_none()
|
||||
{
|
||||
return match self.save_as(file_dialogs::AfterSave::Nothing) {
|
||||
file_dialogs::Asked::Done(r) => r,
|
||||
file_dialogs::Asked::Pending => Ok(json!({"pending": "saveAs"})),
|
||||
file_dialogs::Asked::Busy => Ok(json!({"saved": false})),
|
||||
};
|
||||
}
|
||||
let retry = (id == "file.open").then(|| params.clone());
|
||||
let r = self.execute(id, params);
|
||||
if let (Err(e), Some(params)) = (&r, retry)
|
||||
@@ -1800,6 +1814,16 @@ impl WordApp {
|
||||
r
|
||||
}
|
||||
|
||||
/// These paths have readers but no writer, so an interactive save needs another format.
|
||||
fn legacy_word_path(&self) -> bool {
|
||||
self.session
|
||||
.path
|
||||
.as_ref()
|
||||
.and_then(|p| p.extension())
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("doc") || e.eq_ignore_ascii_case("dot"))
|
||||
}
|
||||
|
||||
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
|
||||
/// otherwise .docx.
|
||||
fn default_save_name(&self) -> String {
|
||||
@@ -2119,6 +2143,15 @@ impl WordApp {
|
||||
raw.events.push(egui::Event::PointerMoved(*p));
|
||||
}
|
||||
let n = n.min(self.synthetic.len());
|
||||
if self.synthetic.iter().take(n).any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) {
|
||||
// Control input belongs to this window's remembered widget even while another app
|
||||
// is active. Give only this input frame egui focus, without requesting OS activation.
|
||||
raw.focused = true;
|
||||
if let (Some(ctx), Some(id)) = (&self.ctx, self.canvas.widget_id) {
|
||||
// Canvas keys run before painting updates last frame's native focus flag.
|
||||
self.canvas.focused = ctx.memory(|m| m.has_focus(id));
|
||||
}
|
||||
}
|
||||
raw.events.extend(self.synthetic.drain(..n));
|
||||
}
|
||||
|
||||
@@ -4130,6 +4163,126 @@ mod tests {
|
||||
frame(&mut a, vec![btn(ribbon, false)]);
|
||||
}
|
||||
|
||||
fn control_frame(a: &mut WordApp, ctx: &egui::Context, focused: bool) {
|
||||
let mut raw = egui::RawInput {
|
||||
focused,
|
||||
time: Some(ctx.input(|i| i.time) + 1.0 / 60.0),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))),
|
||||
..Default::default()
|
||||
};
|
||||
a.raw_input_hook(&mut raw);
|
||||
ctx.run_ui(raw, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
}
|
||||
|
||||
fn control_input(a: &mut WordApp, ctx: &egui::Context, method: &str, params: Value) {
|
||||
let (req, _reply) = ControlRequest::new(method, params);
|
||||
let control::Outcome::Done(v) = control::handle(a, ctx, &req) else { panic!("input is queued") };
|
||||
assert_eq!(v["result"]["queued"], true);
|
||||
}
|
||||
|
||||
/// #2420: an inactive native window still delivers control input to its focused widget.
|
||||
#[test]
|
||||
fn control_keyboard_reaches_the_background_document_once() {
|
||||
for focused in [true, false] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("document.setText", json!({"text": "Seed"})).unwrap();
|
||||
for _ in 0..4 {
|
||||
control_frame(&mut a, &ctx, true);
|
||||
}
|
||||
assert!(a.canvas.focused);
|
||||
for _ in 0..2 {
|
||||
control_frame(&mut a, &ctx, focused);
|
||||
}
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
// Two queued requests are delivered in order across the key's release boundary.
|
||||
control_input(&mut a, &ctx, "ui.key", json!({"key": "b", "cmd": true}));
|
||||
control_input(&mut a, &ctx, "ui.text", json!({"text": "Written"}));
|
||||
control_frame(&mut a, &ctx, focused);
|
||||
assert_eq!(a.session.doc.para_at(&a.session.sel.focus).unwrap().props_of_char(0).bold, Some(true), "focused={focused}: Mod+B applied");
|
||||
assert_eq!(body_text(&a), "Seed", "text waits behind the key's release");
|
||||
control_frame(&mut a, &ctx, focused);
|
||||
assert_eq!(body_text(&a), "Written", "focused={focused}: text applied");
|
||||
assert!(a.synthetic.is_empty());
|
||||
let rev = a.session.rev();
|
||||
for _ in 0..3 {
|
||||
control_frame(&mut a, &ctx, true);
|
||||
}
|
||||
assert_eq!(a.session.rev(), rev, "foregrounding never replays an input");
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(body_text(&a), "Seed", "one Undo reverses the one text input");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_keyboard_keeps_the_background_widget_target() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("document.setText", json!({"text": "Seed"})).unwrap();
|
||||
for _ in 0..4 {
|
||||
control_frame(&mut a, &ctx, true);
|
||||
}
|
||||
a.run("edit.advancedFind", json!({})).unwrap();
|
||||
for _ in 0..2 {
|
||||
control_frame(&mut a, &ctx, true);
|
||||
}
|
||||
assert!(ctx.memory(|m| m.has_focus(egui::Id::new("find_what"))));
|
||||
for _ in 0..2 {
|
||||
control_frame(&mut a, &ctx, false);
|
||||
}
|
||||
control_input(&mut a, &ctx, "ui.text", json!({"text": "Needle"}));
|
||||
control_frame(&mut a, &ctx, false);
|
||||
assert!(matches!(&a.dialog, Some(dialogs::Dialog::Find { query, .. }) if query == "Needle"));
|
||||
control_input(&mut a, &ctx, "ui.key", json!({"key": "Backspace"}));
|
||||
control_frame(&mut a, &ctx, false);
|
||||
assert!(matches!(&a.dialog, Some(dialogs::Dialog::Find { query, .. }) if query == "Needl"));
|
||||
assert_eq!(body_text(&a), "Seed", "the Find field, not the document, owns the input");
|
||||
control_input(&mut a, &ctx, "ui.key", json!({"key": "Escape"}));
|
||||
control_frame(&mut a, &ctx, false);
|
||||
assert!(a.dialog.is_none());
|
||||
a.run("file.close", json!({})).unwrap();
|
||||
for focused in [true, true, false, false] {
|
||||
control_frame(&mut a, &ctx, focused);
|
||||
}
|
||||
assert!(matches!(a.dialog, Some(dialogs::Dialog::SaveChanges { .. })));
|
||||
control_input(&mut a, &ctx, "ui.key", json!({"key": "Escape"}));
|
||||
control_frame(&mut a, &ctx, false);
|
||||
assert!(a.dialog.is_none());
|
||||
assert!(a.session.dirty && a.has_document() && !a.quit_requested);
|
||||
assert_eq!(body_text(&a), "Seed", "Cancel preserves the unsaved document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_keyboard_preserves_native_focus_and_document_protection() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
a.run("document.setText", json!({"text": "Seed"})).unwrap();
|
||||
for _ in 0..4 {
|
||||
control_frame(&mut a, &ctx, true);
|
||||
}
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
a.run("review.restrict", json!({"mode": "readOnly"})).unwrap();
|
||||
let doc = a.session.doc.clone();
|
||||
for _ in 0..2 {
|
||||
control_frame(&mut a, &ctx, false);
|
||||
}
|
||||
control_input(&mut a, &ctx, "ui.key", json!({"key": "b", "cmd": true}));
|
||||
control_input(&mut a, &ctx, "ui.text", json!({"text": "Refused"}));
|
||||
for _ in 0..3 {
|
||||
control_frame(&mut a, &ctx, false);
|
||||
}
|
||||
assert_eq!(a.session.doc, doc, "control input respects the same protection as real keys");
|
||||
assert!(a.synthetic.is_empty());
|
||||
let mut native = egui::RawInput { focused: false, ..Default::default() };
|
||||
a.raw_input_hook(&mut native);
|
||||
assert!(!native.focused, "a frame without control keys keeps the native focus state");
|
||||
assert!(!a.canvas.focused);
|
||||
}
|
||||
|
||||
/// The control channel's `ui.wheel` scrolls the canvas like a mouse wheel over it.
|
||||
#[test]
|
||||
fn control_wheel_scrolls_the_canvas() {
|
||||
@@ -4824,6 +4977,60 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1015: focusing Save is not enough: its dialog must expose the question and any failed
|
||||
/// save's explanation, so assistive technology has context for the three choices.
|
||||
#[test]
|
||||
fn save_prompt_exposes_its_context_to_accessibility() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{By, NodeT, Queryable};
|
||||
let dir = scratch("save-accessibility");
|
||||
let path = docx_on_disk(&dir, "Draft");
|
||||
let mut a = app();
|
||||
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
a.run("text.insert", json!({"text": "Revised "})).unwrap();
|
||||
a.canvas.want_focus = true;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(6);
|
||||
assert!(h.state().canvas.focused);
|
||||
h.state_mut().run("file.close", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let save = h.query_all_by_role(Role::Button).find(|n| n.accesskit_node().label().as_deref() == Some("Save") && n.is_focused()).unwrap();
|
||||
let mut ancestor = save.parent();
|
||||
let mut roles = Vec::new();
|
||||
while let Some(node) = ancestor {
|
||||
roles.push(node.accesskit_node().role());
|
||||
ancestor = node.parent();
|
||||
}
|
||||
assert!(roles.contains(&Role::AlertDialog), "Save has no alert-dialog ancestor: {roles:?}");
|
||||
let dialog = h.get(By::new().predicate(|n| n.role() == Role::AlertDialog));
|
||||
let node = dialog.accesskit_node();
|
||||
assert!(node.label().is_some_and(|s| s.contains("contract.docx") && s.contains("save changes")));
|
||||
assert!(node.description().is_some_and(|s| s.contains("Your changes will be lost")));
|
||||
for label in ["Save", "Don't Save", "Cancel"] {
|
||||
assert!(dialog.query_by_role_and_label(Role::Button, label).is_some(), "missing {label}");
|
||||
}
|
||||
let reason = "Couldn't save contract.docx: disk full";
|
||||
if let Some(dialogs::Dialog::SaveChanges { message, .. }) = &mut h.state_mut().dialog {
|
||||
*message = reason.into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
let dialog = h.get(By::new().predicate(|n| n.role() == Role::AlertDialog));
|
||||
assert!(dialog.accesskit_node().description().is_some_and(|s| s.contains(reason)));
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert!(h.state().canvas.focused && h.state().session.dirty);
|
||||
assert!(body_text(h.state()).contains("Revised Draft"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `ui.print` hands the document's PDF to the host's print hook, and is an error (with
|
||||
/// nothing printed) when the host has none.
|
||||
#[test]
|
||||
|
||||
@@ -76,6 +76,17 @@ in [MCP server](mcp.md#character-and-paragraph-properties).
|
||||
|
||||
Positions are `{"story": "body" | {"part": n}, "path": [block, row, cell, block, …], "off": byteOffset}`.
|
||||
|
||||
### Keyboard and text input
|
||||
|
||||
`ui.key` and `ui.text` go to this window's focused widget even when another app is in front
|
||||
(#2420). A focused Find field receives the text; a document receives it only while its canvas
|
||||
owns keyboard focus. Shortcuts, dialogs and document protection still apply. These methods do
|
||||
not activate the native window. `ui.focus` separately requests native activation.
|
||||
|
||||
The `{"queued": true}` reply acknowledges the input queue, not a completed edit. Input is applied
|
||||
on subsequent UI frames. Inspect the resulting document or widget state before continuing with
|
||||
an operation that depends on it; do not replay an edit simply because its effect is not immediate.
|
||||
|
||||
### Closing the document
|
||||
|
||||
`engine.execute` with `file.close` never shows the Save / Don't Save / Cancel prompt. On a
|
||||
|
||||
@@ -36,7 +36,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| `.docx` page background picture/texture (`v:background/v:fill`) | — | R/W | ✅ | ✅ | ~80% | — | `roundtrip.rs::page_background_picture_round_trips`, `crates/render` | Image part and `type`/`o:title` kept; tiled at 96 dpi or stretched; drawn on screen and in PDF; no Fill Effects dialog yet, gradients and patterns are not read |
|
||||
| `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below |
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | one fixture | Writing Strict is rare |
|
||||
| `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) |
|
||||
| `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run |
|
||||
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). |
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
@@ -87,6 +87,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
|
||||
| WordArt text effects (`w14:textFill`, `w14:textOutline`, `w14:shadow`, `w14:glow`, `w14:reflection` in `w:rPr`) | ✅ | ✅ | Solid, gradient (linear) and no fill, outline colour/width, shadow, glow, reflection (#416); `w14:scene3d`/`w14:props3d` dropped |
|
||||
| Legacy font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint` in `w:rPr`) | ✅ | ✅ | Drawn on screen and in PDFs as Word's PDFs show them (#2534): hollow outlined letters; a sharp grey copy down-right (shadow, emboss) or up-left (imprint); WordArt effects on the same run win; tracked insertions and deletions keep them. Not drawn on colour emoji, or on top-to-bottom text with upright East Asian characters. Emboss and imprint are approximations (no highlight edge) |
|
||||
| OpenType ligatures (`w14:ligatures` in `w:rPr`: runs, styles, document defaults) | ✅ | ✅ | All 16 values; an unknown value is ignored; nothing is written when the document has none |
|
||||
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset (callouts and flowchart boxes drawn as the nearest shape, #611); `flowChartOr` draws a circle with horizontal and vertical spokes; rounded rectangles keep their own corner (`roundRect` `adj`: read, written back, drawn on screen and in PDF, #1476); fill and outline from the shape or from the theme through its style references (`a:fillRef`/`a:lnRef`, #611), scheme, preset, system, scRGB and HSL colours; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds; `wps:bodyPr` `vert` and `anchor`, internal margins (`lIns`/`tIns`/`rIns`/`bIns`) and Resize shape to fit text (`a:spAutoFit`: the text is never cut, #743), linked text boxes (`wps:txbx/@id`, `wps:linkedTxbx` `id`/`seq`, #369); text warps (`a:prstTxWarp` with adjust values, all 41 presets kept; unknown ones dropped), connectors (`straightConnector1`, `bentConnector2`–`5`, `curvedConnector2`–`5` read; 3-segment forms written) with glued ends (`wps:cNvCnPr` `a:stCxn`/`a:endCxn`; the glued shapes keep their drawing ids) and arrowheads (`a:headEnd`/`a:tailEnd`, drawn as triangles), including custom-geometry paths (#416). Hidden drawings (`wp:docPr/@hidden`, a group member's `cNvPr/@hidden`) are kept and saved but neither drawn nor laid out, nor exported to other formats, as in Word for Windows; `.doc` and RTF shapes don't read their hidden flag yet |
|
||||
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
|
||||
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes, freeforms (custom geometry, #743; a path drawn back to its start without `a:close` is filled, as in Word) and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
|
||||
@@ -97,7 +98,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. No native stroke rendering, eraser, conversion or replay; ink nested in groups/canvases remains unsupported |
|
||||
| 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
|
||||
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
|
||||
| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`) not used for rendering, but the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip |
|
||||
| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`, `w:embedBold`…) lay out and export the text set in them for the session (#1176), subsets Word embeds ("embed only the characters used", a made-up family such as `___WRD_EMBED_SUB_46` inside) found by the font table's name (#2240); the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip |
|
||||
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) |
|
||||
| Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) |
|
||||
| Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/section_valign.rs`). ODT/RTF don't carry it |
|
||||
@@ -215,6 +216,12 @@ attributes inside stories and style or list definitions; a regenerated theme. Te
|
||||
|
||||
|
||||
|
||||
## Saving legacy Word imports
|
||||
|
||||
Word 97–2003 `.doc` and `.dot` remain import-only. A user Save without an explicit destination opens the existing Save As flow suggesting DOCX (#2445), including before Close. The original stays unchanged. Cancelling retains the draft, revision and path; a failed write keeps the draft open, and successful DOCX save/reopen retains the edited text. Uppercase extensions follow the same route. Scripts and explicitly supplied legacy destinations still receive an unsupported-format error and never open a dialog. Late Save As answers are rejected before writing if automation has replaced the document.
|
||||
|
||||
Original binary fixtures and actual WordApp/Session file I/O cover Save, toolbar dispatch, a Mod+S input event, cancel, failure, retry, close continuation and repeated DOCX saves. The picker service is controlled in these tests; a native Windows picker and broad legacy-document fidelity remain unqualified.
|
||||
|
||||
## Cached field result formatting
|
||||
|
||||
Imported text fields use the first nonempty cached result run's formatting on their paragraph span, including character styles. Empty results retain begin-marker formatting. Synthetic fixtures cover simple and complex fields, default inheritance and repeated saves. Mixed-format results remain represented by one span; field refresh policy is unchanged.
|
||||
@@ -225,7 +232,11 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | RTF export writes sections: `\sectd` formatting and `\sect` per section, round-tripping through the reader (gap 19) |
|
||||
| 2026-10-14 | trivial | `ASK` and `SET` fields keep their result but show nothing (#1765) |
|
||||
| 2026-10-16 | trivial | Strict OOXML line heights: `w:line="13.90pt"` under `lineRule="auto"` is read as Word renders it — through twips (13.90 × 20 = 278; the 1.1583 multiple), not as 240ths of a line — so a Word 2024 strict file opens at Word's line spacing and page count (#2329) |
|
||||
| 2026-10-14 | trivial | ODT export writes sections: each new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is a columned `text:section` (gap 19) |
|
||||
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
|
||||
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
|
||||
| 2026-10-16 | trivial | Fonts Word embeds as subsets are found by the font table's name (`OBRBWW+Century Schoolbook`), not only the family inside the file, so the text set in them uses them instead of a substitute (#2240) |
|
||||
| 2026-10-17 | trivial | Paste: an HTML `<blockquote>` (LibreOffice's both-sides indent) pastes as a Normal paragraph with its margins as indents, not the centred italic Quote style; opening .html unchanged (#1270) |
|
||||
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
|
||||
| 2026-10-16 | trivial | Cached textual `REF` results with `\h` export links to their bookmarks. Code view and existing enclosing hyperlinks remain unchanged; unresolved bookmarks emit no PDF link. |
|
||||
@@ -341,3 +352,4 @@ Imported text fields use the first nonempty cached result run's formatting on th
|
||||
| 2026-10-13 | trivial | DOCX styles without any `w:qFormat` (LibreOffice) get Word's usual Styles gallery: matching built-in styles are marked quick and missing quick built-ins added (#1238) |
|
||||
| 2026-10-12 | trivial | Recipient lists: legacy .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 CSV/TXT (#778); plain-text import detects legacy encodings too |
|
||||
| 2026-10-15 | trivial | DOCX: runs mixing right-to-left and left-to-right text are split by direction on save; right-to-left pieces get `w:rtl` and `w:lang w:bidi`, so Word shows the order WordCraft shows (#1543) |
|
||||
| 2026-10-16 | trivial | DOCX: OpenType ligatures (`w14:ligatures`) read and written in runs, styles and document defaults |
|
||||
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -20,6 +20,12 @@ establish cross-platform reliability.
|
||||
|
||||
Document renaming: desktop renaming requires a filesystem that supports hard links (exFAT/FAT drives and some network shares refuse it). Behaviour: [UI parity](ui-parity.md).
|
||||
|
||||
Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. Other pagination gaps remain open.
|
||||
|
||||
Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. Other pagination gaps remain open.
|
||||
|
||||
Legacy Word imports (#2445): interactive Save for `.doc`/`.dot` uses the existing DOCX Save As dialog. Cancel and write failures retain the draft and original path; Save before Close continues only after a successful supported-format save. This narrows save reliability under gap #3; binary `.doc` writing remains unsupported. Late Save As answers are rejected before writing if automation has replaced the document.
|
||||
|
||||
## Ranked
|
||||
|
||||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
@@ -43,7 +49,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin
|
||||
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) |
|
||||
| 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) |
|
||||
@@ -90,6 +96,8 @@ The Language dialog reads effective character formatting, including document def
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
|
||||
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
|
||||
| 2026-10-16 | trivial | #24: the Spike (Ctrl+F3, Ctrl+Shift+F3) |
|
||||
| 2026-10-17 | trivial | Glossary/building blocks closed (DOCX glossary read, kept and written); `w:altChunk` and sub-documents remain |
|
||||
| 2026-10-15 | trivial | #5 narrowed: Track Changes Options › Moves (Moved from / Moved to marks and colours) and Track moves (`w:doNotTrackMoves`) landed; drag-and-drop moves remain |
|
||||
@@ -107,9 +115,13 @@ The Language dialog reads effective character formatting, including document def
|
||||
| 2026-10-16 | trivial | #17: automatic space between Asian text and Latin text or digits laid out (#2358) |
|
||||
| 2026-10-14 | trivial | Gap 19: RTF export writes sections (ODT export landed too) |
|
||||
| 2026-10-14 | trivial | #19 narrowed: ODT export done (master page and page layout per section, columned `text:section`s) |
|
||||
| 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
|
||||
| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
|
||||
| 2026-10-16 | trivial | Zero-extent drawing anchors contribute no inline font height. Synthetic regressions. |
|
||||
| 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
| 2026-10-16 | trivial | Gap #20: opening a DOCX no longer copies its pictures |
|
||||
| 2026-10-12 | minor | Gap #24 default shortcuts expanded (140 commands on macOS / 158 on Linux/Windows); stale gaps #9 (DOCX agile encryption already works, only encrypted .doc missing) and #15 (DOCX bibliography sources already saved, only desktop integrations missing) updated |
|
||||
| 2026-10-13 | trivial | Previously untracked long Web/Draft page blur resolved with visible high-resolution raster tiling; no gap remains for this issue |
|
||||
| 2026-10-14 | trivial | #1: VML freeforms (rule lines in headers) read and kept on save |
|
||||
|
||||
+18
-1
@@ -12,6 +12,10 @@ pagination and object placement in `crates/layout`. Typing behaviour is in
|
||||
rules, but nothing has been compared page-for-page with Word on real documents, and the default
|
||||
font can't match Word's metrics.
|
||||
|
||||
Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. Synthetic control: `layout::tests::section_start_summed_spacing`.
|
||||
|
||||
Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. Synthetic control: `layout::tests::exact_math_keeps_run_baseline`.
|
||||
|
||||
## Checklist
|
||||
|
||||
✅ matches Word as far as our tests show · 🟡 implemented, differs or untested against Word · ❌ missing
|
||||
@@ -21,6 +25,7 @@ font can't match Word's metrics.
|
||||
| 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. |
|
||||
| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. | — |
|
||||
| Line breaking (UAX #14 with Word's exceptions: no break after `/`) | ✅ | `layout::tests::no_line_break_right_after_a_slash` | — |
|
||||
| Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 |
|
||||
| Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109 | — |
|
||||
| Spaces at the end of a line hang past the right margin instead of wrapping; the caret after them stays at the margin, in the body and in headers and footers | ✅ | `layout::tests::caret_after_hanging_spaces_stays_at_the_margin` (#2146): a letter typed after them wraps to the next line | — |
|
||||
| Complex scripts (Myanmar, Khmer, Indic): shaped per script run with OpenType fallback fonts, one cluster per syllable, Myanmar wraps between syllables | 🟡 | `layout::tests_complex` (#604). Khmer, Thai and Lao still wrap only at spaces (need a dictionary) | 4–8 |
|
||||
@@ -35,6 +40,7 @@ font can't match Word's metrics.
|
||||
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); other line-spacing combinations remain (#108, #135 open) | 3–5 |
|
||||
| Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — |
|
||||
| OpenType ligatures as the document asks (`w14:ligatures`: standard `liga`, contextual `clig`, historical `hlig`, discretional `dlig`, any combination, in runs, styles and defaults) | 🟡 | `layout::tests::ligatures_follow_w14_ligatures`, `ligature_flags_map_to_opentype_features`. Without the property WordCraft keeps drawing standard and contextual ligatures, while Word draws none; the Font dialog's Ligatures choice is still missing | 1 |
|
||||
| Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — |
|
||||
| Paragraph boundaries inside field instructions | ✅ | Synthetic DOCX observed in Word: instruction-only paragraphs add no height; the empty result keeps a line. `docx::fixtures::field_code_spans_paragraphs`, `layout::tests::hidden_marks_take_no_space`. Other field-code display modes remain unverified | — |
|
||||
| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`); a three-line paragraph with room for two moves whole (neither a widow nor an orphan is allowed), as in Word | — |
|
||||
@@ -49,7 +55,7 @@ font can't match Word's metrics.
|
||||
| Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — |
|
||||
| Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — |
|
||||
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 |
|
||||
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — |
|
||||
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — |
|
||||
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 |
|
||||
| Nested tables | 🟡 | render; polish missing | 2–4 |
|
||||
@@ -185,6 +191,8 @@ When content exceeds a preferred column width, AutoFit donors lose equal widths
|
||||
|
||||
Behind-text pictures in table cells participate in centre and bottom vertical alignment without increasing row flow height.
|
||||
|
||||
TOC refresh reuses direct tab formatting from the first cached entry with a number-separator stop at each level for numbered entries, including imported range fields. Page-number tabs still use the TOC section width. No new default number-tab position is inferred.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
@@ -206,6 +214,13 @@ Behind-text pictures in table cells participate in centre and bottom vertical al
|
||||
| 2026-10-16 | trivial | `w:autoSpaceDE` and `w:autoSpaceDN` are laid out: a quarter em between Asian text and Latin text or digits (#2358) |
|
||||
| 2026-10-16 | trivial | AutoFit donors lose equal widths, bounded by content minima |
|
||||
| 2026-10-16 | trivial | Behind-text pictures in table cells participate in centre and bottom vertical alignment without increasing row flow height. |
|
||||
| 2026-10-15 | trivial | Absent or zero DrawingML effect extents remain zero on rotated pictures; contracted axes no longer reserve invented effects room. Synthetic quarter-turn import regression (`zero_effects_stay_zero_rotated`). |
|
||||
| 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
|
||||
| 2026-10-15 | trivial | Removed the 12 pt line-width floor in narrow cells and float spans; synthetic wrapping and oversized-glyph controls |
|
||||
| 2026-10-15 | trivial | Partial cell margins retain omitted edges through DOCX save and inherit current table defaults during layout. Original import/write and row-height regressions. |
|
||||
| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
|
||||
| 2026-10-16 | trivial | Zero-extent drawing anchors contribute no inline font height. Synthetic regressions. |
|
||||
| 2026-10-16 | trivial | Preserve direct TOC number tabs during regeneration |
|
||||
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
|
||||
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
|
||||
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |
|
||||
@@ -242,6 +257,7 @@ Behind-text pictures in table cells participate in centre and bottom vertical al
|
||||
| 2026-10-12 | trivial | A list label's font raises the first line's ascent but never its descent (Word keeps the text's descent below the baseline) |
|
||||
| 2026-10-12 | trivial | Hidden end-of-cell mark (`w:hideMark`): a cell's empty last paragraph takes no room, spacing and all; a row of only such empty cells is just its borders |
|
||||
| 2026-10-12 | trivial | Text frames in headers, footers, cells and text boxes are placed like floating objects with the text wrapping around them; body frames still lay out in line |
|
||||
| 2026-10-16 | trivial | Framed paragraphs inside a text box (a frame from their style, usually) run with the box's text as in Word, instead of being placed apart on top of each other (#2357) |
|
||||
| 2026-10-12 | trivial | List indents and style indents combine like Word's: a numbered style's own indents (and those of styles based on it) beat the list level's, its base styles' don't, the paragraph's own list beats every style; a paragraph that switches its list off (`numId` 0) drops the styles' indents |
|
||||
| 2026-10-12 | trivial | Keep with next: a paragraph that doesn't fit on the page anyway breaks as usual (its last lines go over with the next paragraph) instead of moving whole; a chain of kept paragraphs moves together |
|
||||
| 2026-10-12 | trivial | Continuous section breaks: an empty paragraph carrying only the break takes no room (text after it follows on; balanced columns ignore it) |
|
||||
@@ -282,6 +298,7 @@ Behind-text pictures in table cells participate in centre and bottom vertical al
|
||||
| 2026-10-10 | trivial | Long display equations wrap across lines (#326) |
|
||||
| 2026-10-10 | trivial | AutoFit Contents measures the text (#44) |
|
||||
| 2026-10-10 | major | First version: layout and pagination checklist with evidence from `crates/layout`, PRs and issues |
|
||||
| 2026-10-16 | trivial | OpenType ligatures follow `w14:ligatures` (none, standard, contextual, historical, discretional and their combinations); without it WordCraft's default stays |
|
||||
|
||||
Maintenance on 2026-10-12 retains current tracked-deletion, endnote, footer-total, text-box
|
||||
and balloon layout while supplying explicit cell identity at every box call. The native Word
|
||||
|
||||
@@ -51,7 +51,7 @@ shared items below.
|
||||
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
|
||||
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
|
||||
| Arabic | ar | 0 | ❌ | shaping, joining, lam-alef, bidi ✅ (#207); no kashida; **no mirrored UI** | no | none | 6–8 |
|
||||
| French | fr | 2,033 entries | most; newer dialogs partly English | Latin ✅ | no | partial | 1–2 |
|
||||
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) |
|
||||
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
|
||||
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
|
||||
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; no vertical text, ruby or document grid | no | partial | 3–5 |
|
||||
@@ -200,6 +200,7 @@ WordCraft keeps 2,54 cm).
|
||||
| 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged |
|
||||
| 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 |
|
||||
| 2026-10-16 | trivial | Linked the new guide to installing the Hunspell dictionaries on Windows, macOS and Linux (`docs/spelling-dictionaries.md`, `pt_BR` as the example); estimates unchanged |
|
||||
| 2026-10-16 | minor | French interface catalog completed (2,615 entries, native-speaker review); proofing still pending |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
| 2026-10-13 | minor | Swedish interface catalog (2,504 entries), locale selection, saved preference, audit coverage and font checks (#1219) |
|
||||
|
||||
@@ -55,6 +55,17 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp
|
||||
| type, select some of it, type over it | two undo steps: the first ⌘Z brings the selected text back (still selected), the second removes the typing. Uninterrupted typing stays one step | `typing_then_selecting_then_typing_are_two_undo_steps` |
|
||||
| ⌘B with nothing selected, ⌘Z, type | the text isn't bold (Undo takes the waiting caret formatting back too); ⌘Y brings it back | `undoing_bold_at_the_caret_turns_bold_typing_off_again` |
|
||||
|
||||
## Paste
|
||||
|
||||
| Keys | Word's result | Test |
|
||||
|---|---|---|
|
||||
| Paste (⌘V) of plain text with several lines from another program, in the middle of a right-to-left paragraph | pasted as Keep Text Only pastes it: every line becomes a paragraph formatted like the paragraph it lands in (direction, style, alignment, indents, list), and so does the text after the caret. One ⌘Z takes the whole paste out ² | `plain_text_paste_gives_every_line_the_formatting_of_its_paragraph` |
|
||||
| … with empty lines, or a line break at the end | each empty line is an empty paragraph with that formatting too, in a list an empty item. The text takes the formatting before the caret (bold there makes it bold) ² | `plain_text_paste_keeps_empty_lines_as_paragraphs`, `plain_text_paste_keeps_an_empty_paragraph_in_a_list` |
|
||||
|
||||
² From Microsoft's description of the paste options: Word offers no options for text without
|
||||
formatting, and Keep Text Only gives text the style of the paragraph where it is pasted and the
|
||||
formatting of the text before the cursor. Not yet compared side by side in Word.
|
||||
|
||||
## Track Changes
|
||||
|
||||
| Keys | Word's result | Test |
|
||||
@@ -297,6 +308,7 @@ Word.
|
||||
| 2026-10-15 | trivial | Input methods: `DeleteSurrounding` erases around the caret, so web typing in non-Latin text no longer repeats; Chrome's caret jump in the hidden field is listed as a known gap (#1480) |
|
||||
| 2026-10-15 | minor | Backspace with a whole table, whole rows or whole columns selected deletes them; Delete clears them; a selection covering a table and the text beside it removes the table (#1844) |
|
||||
| 2026-10-16 | trivial | Smart quotes in Hebrew follow logical order; ” (not gershayim) in acronyms, as Word does (#1670) |
|
||||
| 2026-10-15 | trivial | Paste: plain text from another program pastes as Keep Text Only does. Every line takes the formatting of the paragraph it lands in (a right-to-left paragraph stays right-to-left), and empty lines stay empty paragraphs |
|
||||
| 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) |
|
||||
| 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) |
|
||||
| 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) |
|
||||
|
||||
+9
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (AltGr doesn't open KeyTips, #2388; previously trivial: File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (AltGr doesn't open KeyTips, #2388; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
@@ -28,7 +28,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Modal dialogs | **58** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, its height or 1 × 1 cutting it back into rows, more refused with Word's message; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
|
||||
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
|
||||
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712) | | 68% | 4–6 |
|
||||
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700) | ✅ | 90% | 1 |
|
||||
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — |
|
||||
@@ -55,6 +55,8 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); Windows Magnifier follows the caret, also onto an empty line after Enter or Shift+Enter (#1119); not yet tried with real screen readers | full | 50% | 5–8 |
|
||||
| Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization |
|
||||
|
||||
Save Changes exposes the document-specific question as an alert-dialog name and the loss warning plus any failed-save explanation as its description (#1015). Full-app AccessKit coverage checks the focused choice's dialog ancestry, all three choices and cancellation. This is accessibility-tree evidence; native Windows screen-reader and spoken VoiceOver verification remain pending. Visual layout, wording and save policy are unchanged.
|
||||
|
||||
The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate.
|
||||
|
||||
## Find navigation (#2096)
|
||||
@@ -67,9 +69,11 @@ Find selects the Results tab, including when it prefills selected text. Editing
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | KeyTips: AltGr (Right Alt with Level 3 Shift on Linux) is not an Alt tap, held or tapped, so AltGr+letter types instead of reaching the badges (#2388) |
|
||||
| 2026-10-15 | trivial | File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does (it quit the whole app); the window's close button still asks to save and quits (#1712) |
|
||||
| 2026-10-16 | trivial | Save Changes exposes an alert-dialog role, the document-specific question and loss/failure context to accessibility; full-app focus/ancestry regression. Native screen-reader verification remains pending. |
|
||||
| 2026-10-15 | trivial | Dialogs, the Read Aloud player and the file browser: the close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable; red with a white ✕ while the pointer is over it, darker red pressed, in both themes; Escape as before (#1885) |
|
||||
| 2026-10-15 | trivial | Font box (ribbon and mini toolbar): a Recently Used Fonts section at the top of the list holds the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, once each, ten at most, kept between runs in `ui.json`; All Fonts follows; the sections show while nothing is typed and a query lists its matches only; Up/Down walk the recent entries then the list, Enter applies the highlighted one; a recent font no longer installed stays listed (#2068) |
|
||||
| 2026-10-16 | trivial | Web Layout text fills the window width and re-wraps once a resize settles or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876) |
|
||||
| 2026-10-16 | trivial | Update Field is offered on a table of contents after it was saved and reopened (a range field then), including at its first and last character; repeated save and update no longer adds a blank line (#2182; the flow in that report already worked since #1197) |
|
||||
| 2026-10-18 | trivial | Different First Page: double-clicking page 1's header while editing another one goes into page 1's own; Insert › Header/Footer/Page Number and Edit Header/Footer use the header the caret's page shows (first, even or default) instead of always the default (#2826) |
|
||||
| 2026-10-15 | trivial | Track Changes Options › Moves: Moved from and Moved to rows (mark and colour; green double strikethrough and double underline by default, as in Word for Mac, which has no Track moves checkbox). `review.trackingOptions {"moveFromMark", "moveFromColor", "moveToMark", "moveToColor"}`, saved per user; `"trackMoves"` sets the document's setting. Mark lists in Word for Mac's order; Deletions and Moved from gain (none), Bold, Italic and Double underline; moved text marked (none) is plain text |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
@@ -89,6 +93,9 @@ Find selects the Results tab, including when it prefills selected text. Editing
|
||||
| 2026-10-16 | trivial | Ctrl/⌘+wheel only zooms: a coast or wheel notch still gliding stops instead of scrolling the page on top of the zoom (#1743) |
|
||||
| 2026-10-16 | trivial | The Spike: Ctrl+F3 (⌘F3 on macOS) cuts the selection to the end of the Spike, Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected and empties it; it is the AutoText entry "Spike", so Quick Parts inserts it without emptying it. Per session, not saved between launches |
|
||||
| 2026-10-15 | trivial | Table Layout › Cell Margins opens with current table defaults; unchanged margins and per-cell overrides stay intact until edits are confirmed (#1960) |
|
||||
| 2026-10-15 | trivial | Paste (Ctrl+V, Home › Paste) of plain text from another program: every pasted line takes the formatting of the paragraph it lands in, as with Keep Text Only, so a right-to-left paragraph stays right-to-left after the first line. The lines after the first had default formatting |
|
||||
| 2026-10-14 | trivial | Add Font… takes several files at once (desktop dialog and web picker are multi-select); each is added, a bad file is reported and skipped, at most 2,000 per pick (the status line counts the skipped); on the web each font is added and reported on its own, with no single summary line; the built-in browser still picks one file (#1789) |
|
||||
| 2026-10-16 | trivial | Background control keyboard/text input reaches the focused widget without activating the OS window; document edits, Find, Save Changes cancellation, protection and no-replay controls (#2420) |
|
||||
| 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) |
|
||||
| 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |
|
||||
| 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) |
|
||||
|
||||
Reference in New Issue
Block a user