mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 17:28:58 +00:00
refactor(pdf): reconcile shared writer with current main (PDFW)
Refresh #1558 onto fd0bb2fb while preserving the shared writer migration
and current upstream layout, DOCX, field and table behavior.
This commit is contained in:
commit
df28b78de9
192 files changed
+21154
-1474
No files matched your search
@@ -76,3 +76,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
|
||||
Generated
+1
@@ -5390,6 +5390,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
|
||||
@@ -78,7 +78,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
39adbdf756569c8e03adb747d5e063f468ca98c1. The backend retains its MIT OR Apache-2.0 licensing
|
||||
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
@@ -117,7 +117,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български or Tiếng Việt (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어 or Svenska (follows the system language by default) |
|
||||
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
|
||||
+71
-7
@@ -2,8 +2,8 @@
|
||||
|
||||
**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-14 · **Change:** trivial (Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; 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-14 · **Change:** 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-14 · **Change:** trivial (progress log: print-area strings translated in every catalog; 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.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
@@ -15,7 +15,7 @@ agent control. This page is the summary; the assessment is
|
||||
## Headline numbers (2026-10-10)
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields |
|
||||
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
@@ -109,14 +109,14 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | partial | ~95% |
|
||||
| German | de | partial (#574) | ~95% |
|
||||
| Korean | ko | none | 0% |
|
||||
| Korean | ko | partial (interface catalog; proofing unavailable) | 100% (2,463 entries) |
|
||||
| Vietnamese | vi | partial (#1434) | ~95% |
|
||||
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a
|
||||
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
|
||||
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Hungarian (`hu`) has a
|
||||
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
|
||||
2,448-entry catalog, also without proofing.
|
||||
Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), chosen per run from its language. Word ships
|
||||
Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech (system Hunspell spelling, generic checks), chosen per run from its language. Word ships
|
||||
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -135,7 +135,22 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
| 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-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-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 |
|
||||
| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) |
|
||||
| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 |
|
||||
| 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box |
|
||||
| 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon |
|
||||
| 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) |
|
||||
| 2026-10-14 | A file name that isn't valid Unicode, such as a Latin-1 `café.docx`, no longer crashes `wordcraft-cli` or the app (#1440). The app and the CLI's `convert`, `info`, `text`, `inspect`, `render`, `zotero` and `run --file` open such a file, and saving it without a new name (`run --file … --cmd file.save`, or Save in the app) writes back to it. Any other such argument is an error naming it, an output name included; the app exits with status 2 on a `--control` port that isn't valid Unicode |
|
||||
| 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too |
|
||||
| 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) |
|
||||
| 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) |
|
||||
@@ -152,11 +167,16 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner |
|
||||
| 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file |
|
||||
| 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion |
|
||||
| 2026-10-14 | File › Print translated in all 18 interface languages — printer list, duplex, grayscale, discovery/submission feedback and print commands (18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs) |
|
||||
| 2026-10-14 | All Caps and Small Caps draw ß as SS, as Word does (#1581): "Straße" in All Caps reads STRASSE, not STRASE, and other letters whose capital is two characters (ff, ʼn) draw in full. The ß is still one character: the caret never stops between the two S, and a click on them lands before or after it |
|
||||
| 2026-10-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) |
|
||||
| 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too |
|
||||
| 2026-10-14 | `layout.pageSetup` with a `section` checks its values like Margins, Size and Columns (#1582): non-finite numbers and margins that leave no room for text are refused with nothing changed; page sides, column count and gap, header/footer distances and line-numbering values are clamped to the same ranges |
|
||||
| 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port |
|
||||
| 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") |
|
||||
| 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped |
|
||||
| 2026-10-14 | Lists numbered with Russian letters (`russianLower`, `russianUpper`) show а), б), в) … я), then аа), бб), as in Word, instead of 1), 2), 3); the 29-letter sequence (no ё, й, ъ or ь) was observed in Word |
|
||||
| 2026-10-14 | "Do you want to save changes?" answers from the keyboard after typing too (#1597): Save takes focus from the page when the prompt opens, so the arrow keys and Tab move between Save, Don't Save and Cancel instead of the caret behind it, and Enter answers |
|
||||
| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save |
|
||||
| 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both |
|
||||
| 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon |
|
||||
@@ -168,6 +188,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) |
|
||||
| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged |
|
||||
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
|
||||
| 2026-10-14 | Copied text keeps its formatting in other programs (#1523): Copy and Cut put HTML beside the plain text on the system clipboard for every selection, not only one holding an equation, so a browser, mail client or another word processor pastes bold, italic, fonts and sizes. RTF on the clipboard is not written yet |
|
||||
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
|
||||
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
|
||||
| 2026-10-13 | `file.close` over the control channel or MCP bridge no longer quits and discards unsaved changes (#1268): it returns an error unless the document is saved or `{"force": true}` is passed; the user's Save / Don't Save / Cancel prompt is unchanged |
|
||||
@@ -218,10 +239,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
|
||||
| 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word |
|
||||
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
|
||||
| 2026-10-12 | Comments: Arabic, Hebrew and other non-Latin comment text and author names are drawn with a document fallback face (or the CJK UI font) in the balloons and Comments pane, instead of boxes; read-only balloons show right-to-left text in reading order, set against the right edge |
|
||||
| 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show |
|
||||
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
|
||||
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
|
||||
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
|
||||
| 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders |
|
||||
| 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout |
|
||||
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
|
||||
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
|
||||
@@ -245,6 +268,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it |
|
||||
| 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) |
|
||||
| 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) |
|
||||
| 2026-10-13 | New comments, notes, text boxes and lists in a document whose file used the largest ids (#1338): they take a free id instead of overflowing (a crash) or reusing one already taken (a new list could pick up another's definition); `Document::try_add_part` and `Numbering::try_add_list` report ids running out instead |
|
||||
| 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode |
|
||||
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
|
||||
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
|
||||
@@ -260,6 +284,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-12 | Czech proofing: cs-CZ spelling reads the installed Hunspell dictionary (`hunspell-cs` on Debian/Ubuntu), suggestions use runtime MAP/REP/TRY/KEY tables, generic repeated-word and spacing checks explain in Czech; Review › Language offers Czech/Čeština; the Czech interface writes new documents and opened TXT/Markdown in cs-CZ. Affix headers accept trailing comments. No system dictionary data or Czech hyphenation patterns bundled; parity estimates unchanged |
|
||||
| 2026-10-12 | Tables: dragging the border of one selected cell (Select Cell, or a click along its left edge) moves it in that cell's row only, as in Word, like the border of cells selected across (#962) |
|
||||
| 2026-10-13 | Korean interface catalog completed: all 2,463 entries translated; the ko-KR system locale and saved language choice select the interface. The UI prefers Korean embedded/system font faces so Hangul renders instead of boxes, and print settings and printer feedback are translated. Korean proofing remains unavailable; macOS Korean IME fixes are still pending (#163, #164) |
|
||||
| 2026-10-12 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) |
|
||||
| 2026-10-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction |
|
||||
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
|
||||
| 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` |
|
||||
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
|
||||
@@ -267,6 +296,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
|
||||
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
|
||||
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
|
||||
| 2026-10-14 | A picture or icon inserted by a command (`wordcraft-cli run`, MCP, control channel) leaves the caret after it, so the next `text.newParagraph` or `text.insert` no longer deletes it, and two inserted in a row are both kept; Picture Format commands still act on it, and a picture the user inserts or pastes in the app is still selected (#965) |
|
||||
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
|
||||
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
|
||||
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
|
||||
@@ -277,6 +307,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
|
||||
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
|
||||
| 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` |
|
||||
| 2026-10-14 | `.doc` notes, headers and text boxes read their text once (#1319): a story table that steps back no longer has many footnotes, endnotes, headers or text boxes copy the same text into a part each; a story starting inside an earlier one is dropped (a header or footer left empty is linked to the previous section's), so the stories together never hold more than the file's text |
|
||||
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
|
||||
| 2026-10-14 | ODT and `.doc` pictures stay within their byte budget (#1334): an ODT picture entry is inflated once however many frames point at it, and only when the size it declares fits in what is left (one inflating past that uses the budget up); a `.doc` inline picture that can't be read or doesn't fit is no longer decoded and copied again for every anchor on it, and is decoded only within what is left |
|
||||
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
|
||||
@@ -289,6 +320,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
|
||||
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
|
||||
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
|
||||
| 2026-10-14 | Open DrawingML freeform paths draw `a:tailEnd` at the final path segment |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
|
||||
| 2026-10-13 | Markdown import of text full of backtick runs that never close is linear (#1336): every code span and link bracket over them searched the rest of the text for a closing run (2 MB in brackets: 17.8 s → 0.06 s) |
|
||||
@@ -300,7 +332,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) |
|
||||
| 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written |
|
||||
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
|
||||
| 2026-10-14 | Word XML Document (Flat OPC) reading is bounded everywhere in the file (#1353): nesting and elements are capped as the XML tree reader caps a part's, every child of the package and every part's data count against the part count and size limits (a repeated part name too), and data is checked before it's copied; base64 written as CDATA or with character references reads as the same bytes, and an entity in it is an error instead of bytes gone missing |
|
||||
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
|
||||
| 2026-10-14 | Text boxes in tables nested deeper than 16 survive edits (#1339): the paragraph walk behind text box pruning, copy and paste, reading order, drawing ids and note conversion goes through tables however deep they nest instead of stopping 16 tables down, so an unrelated edit no longer drops the text of a box shown only from deeper, and a copied deep table takes its boxes along |
|
||||
| 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
|
||||
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
|
||||
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
|
||||
@@ -336,7 +370,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
|
||||
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
|
||||
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
|
||||
| 2026-10-13 | Swedish (`sv`) interface: 2,504 catalog entries (every key of the reference catalog, the equation gallery, everything the interface audit finds, and 44 newer strings no catalog had yet, among them the native print settings), `sv-SE`/`sv-FI` system locales and a saved language choice; locale, catalog and bundled-font tests; AI-assisted, terminology and the running app reviewed by a native speaker; no Swedish proofing resources |
|
||||
| 2026-10-12 | Bulgarian (`bg`) interface: 2,419 catalog entries (every key of the reference catalog, the equation gallery and everything the interface audit finds), `bg-BG` system locales and a saved language choice; locale, catalog and bundled-font tests; no Bulgarian proofing resources |
|
||||
| 2026-10-13 | Croatian (`hr`) interface: 2,507 catalog entries (every key of the reference catalog, built-in style names and the equation gallery), `hr-*` system locales and a saved language choice; locale, catalog and bundled-font tests; no Croatian proofing yet |
|
||||
| 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources |
|
||||
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
|
||||
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
|
||||
@@ -367,6 +403,21 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-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 |
|
||||
| 2026-10-14 | trivial | Progress log: consistent preferred cell widths |
|
||||
| 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
|
||||
| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. |
|
||||
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
|
||||
| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) |
|
||||
| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged |
|
||||
| 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) |
|
||||
| 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) |
|
||||
| 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too |
|
||||
| 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) |
|
||||
| 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) |
|
||||
@@ -380,11 +431,16 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) |
|
||||
| 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) |
|
||||
| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage |
|
||||
| 2026-10-14 | trivial | Progress log: print-area strings translated in every catalog |
|
||||
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps draw ß as SS (#1581) |
|
||||
| 2026-10-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) |
|
||||
| 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) |
|
||||
| 2026-10-14 | trivial | Progress log: Page Setup sections are validated (#1582) |
|
||||
| 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) |
|
||||
| 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) |
|
||||
| 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) |
|
||||
| 2026-10-14 | trivial | Progress log: Russian letter list labels |
|
||||
| 2026-10-14 | trivial | Progress log: the save prompt takes the keyboard from the page (#1597) |
|
||||
| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings |
|
||||
| 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) |
|
||||
| 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) |
|
||||
@@ -427,6 +483,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) |
|
||||
| 2026-10-12 | trivial | Progress log: Down and Up no longer skip a table (#1007) |
|
||||
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
|
||||
| 2026-10-12 | trivial | Progress log: table style editor (#714) |
|
||||
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
|
||||
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
|
||||
| 2026-10-11 | trivial | Progress log: the table context menu |
|
||||
@@ -447,6 +504,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
|
||||
| 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) |
|
||||
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
|
||||
| 2026-10-14 | trivial | Progress log: a picture inserted by a command survives the next typing (#965) |
|
||||
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
|
||||
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
|
||||
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
|
||||
@@ -479,6 +537,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
|
||||
| 2026-10-11 | trivial | Progress log: content controls (#381) |
|
||||
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
|
||||
| 2026-10-13 | trivial | Progress log: free part, list and comment ids after a file's largest ones (#1338) |
|
||||
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
|
||||
| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) |
|
||||
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
|
||||
@@ -499,9 +558,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-13 | trivial | Swedish interface (`sv`): progress log and shipped languages |
|
||||
| 2026-10-12 | trivial | Bulgarian interface (`bg`): progress log and shipped languages |
|
||||
| 2026-10-13 | trivial | Croatian interface (`hr`): progress log and shipped languages |
|
||||
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
|
||||
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
|
||||
| 2026-10-14 | trivial | Progress log: `.doc` notes, headers and text boxes read their text once (#1319) |
|
||||
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
|
||||
| 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged |
|
||||
| 2026-10-12 | minor | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
|
||||
@@ -513,8 +575,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
|
||||
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
|
||||
| 2026-10-10 | trivial | Progress log: contour wrap (#354) |
|
||||
| 2026-10-14 | trivial | Progress log: text boxes in deeply nested tables kept through edits (#1339) |
|
||||
| 2026-10-10 | trivial | Progress log: free rotation and flips (#332) |
|
||||
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
|
||||
| 2026-10-14 | trivial | Progress log: Flat OPC reading bounded everywhere in the file, CDATA base64 read (#1353) |
|
||||
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
|
||||
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
|
||||
| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% |
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
mod automation_args;
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -58,16 +60,43 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
|
||||
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
|
||||
}
|
||||
|
||||
fn open(path: &str) -> Result<Session, String> {
|
||||
fn open(path: &Path) -> Result<Session, String> {
|
||||
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
|
||||
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
|
||||
let region = system_region();
|
||||
let (doc, _) = wordcraft_engine::io::open_path_in(std::path::Path::new(path), None, ®ion)?;
|
||||
let (doc, _) = wordcraft_engine::io::open_path_in(path, None, ®ion)?;
|
||||
let mut s = session(doc);
|
||||
s.path = Some(path.into());
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
|
||||
/// `pos` counts). `run` opens the value of `--file`.
|
||||
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
|
||||
|
||||
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
|
||||
/// such as a Latin-1 one (#1440): it comes back as the path to open, and lossy among the arguments.
|
||||
/// Any other such argument is an error naming it.
|
||||
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
|
||||
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
|
||||
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
|
||||
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
|
||||
None => None,
|
||||
};
|
||||
let mut file = None;
|
||||
for (i, a) in raw.iter().map(AsRef::as_ref).enumerate() {
|
||||
if a.to_str().is_none() {
|
||||
if Some(i) != file_at {
|
||||
return Err(format!("argument {} is not valid Unicode: {}", i + 1, a.to_string_lossy()));
|
||||
}
|
||||
file = Some(PathBuf::from(a));
|
||||
}
|
||||
}
|
||||
Ok((args, file))
|
||||
}
|
||||
|
||||
/// A headless session in the system region: every way the CLI starts one gives `file.new` the
|
||||
/// same paper (#1381).
|
||||
fn session(doc: wordcraft_doc::Document) -> Session {
|
||||
@@ -262,7 +291,10 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), Failure> {
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let (args, file) = text_args(args)?;
|
||||
// Opens the file by its real name, which its text in `args` may have lost.
|
||||
let open = |text: &str| open(file.as_deref().unwrap_or(Path::new(text)));
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
|
||||
// Before options are checked or files and folders opened.
|
||||
@@ -524,7 +556,7 @@ fn parity_markdown(p: &Value) -> String {
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
match run(&args) {
|
||||
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
|
||||
Err(Failure::Error(e)) => {
|
||||
@@ -654,6 +686,38 @@ mod tests {
|
||||
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
|
||||
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
|
||||
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
|
||||
// Every command that opens a file reaches the open with it (no such file here), so
|
||||
// `OPENS_FILE` and the commands' own arguments agree.
|
||||
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
|
||||
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
|
||||
.iter()
|
||||
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
|
||||
.collect();
|
||||
opening.push(vec!["run".into(), "--file".into(), missing()]);
|
||||
for raw in opening {
|
||||
let e = run(&raw).unwrap_err().to_string();
|
||||
assert!(e.contains("No such file"), "{raw:?}: {e}");
|
||||
}
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_are_an_error_with_connect() {
|
||||
let dir = std::env::temp_dir().to_string_lossy().into_owned();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the
|
||||
//! file a command opens opens, and any other such argument is an error naming it. Not on macOS:
|
||||
//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there.
|
||||
#![cfg(all(unix, not(target_os = "macos")))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[OsString]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A temp folder holding `caf\xe9.txt`, whose text is "Bonjour".
|
||||
fn folder(name: &str) -> (PathBuf, OsString) {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-non-utf8-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join(OsString::from_vec(b"caf\xe9.txt".to_vec()));
|
||||
std::fs::write(&file, "Bonjour\n").unwrap();
|
||||
(dir, file.into_os_string())
|
||||
}
|
||||
|
||||
fn ok(out: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_a_command_opens_may_have_any_name() {
|
||||
let (dir, file) = folder("open");
|
||||
assert_eq!(ok(&cli(&["text".into(), file.clone()])).trim(), "Bonjour");
|
||||
assert!(ok(&cli(&["info".into(), file.clone()])).contains("\"words\""));
|
||||
assert!(ok(&cli(&["inspect".into(), file.clone()])).contains("Bonjour"));
|
||||
|
||||
let md = dir.join("out.md");
|
||||
ok(&cli(&["convert".into(), file.clone(), md.clone().into()]));
|
||||
assert!(std::fs::read_to_string(&md).unwrap().contains("Bonjour"));
|
||||
|
||||
let saved = dir.join("run.txt");
|
||||
ok(&cli(&["run".into(), "--file".into(), file.clone(), "--save".into(), saved.clone().into()]));
|
||||
assert!(std::fs::read_to_string(&saved).unwrap().contains("Bonjour"));
|
||||
|
||||
// Saving without a path writes back to the file under its real name, not a lossy one.
|
||||
let mut args: Vec<OsString> = vec!["run".into(), "--file".into(), file.clone()];
|
||||
for cmd in ["caret.docEnd", "text.insert={\"text\":\" encore\"}", "file.save"] {
|
||||
args.extend(["--cmd".into(), cmd.into()]);
|
||||
}
|
||||
ok(&cli(&args));
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains("encore"));
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "a second file was written");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_such_argument_is_an_error_naming_it() {
|
||||
let (dir, file) = folder("error");
|
||||
let out_name = dir.join(OsString::from_vec(b"caf\xe9.md".to_vec())).into_os_string();
|
||||
let cmd = OsString::from_vec(b"text.insert={\"text\":\"\xe9\"}".to_vec());
|
||||
for (args, n) in [
|
||||
(vec!["convert".into(), file.clone(), out_name.clone()], 3),
|
||||
(vec!["run".into(), "--file".into(), file.clone(), "--cmd".into(), cmd], 5),
|
||||
(vec![OsString::from_vec(b"t\xe9xt".to_vec()), file.clone()], 1),
|
||||
] {
|
||||
let out = cli(&args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let shown = args[n - 1].to_string_lossy();
|
||||
assert!(shown.contains('\u{FFFD}'));
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?}: stderr: {stderr}");
|
||||
assert_eq!(stderr, format!("wordcraft-cli: argument {n} is not valid Unicode: {shown}\n"), "{args:?}");
|
||||
}
|
||||
assert!(!std::path::Path::new(&out_name).exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when
|
||||
//! the next commands type (#965).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// A 1x1 PNG.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn typing_after_insert_picture_keeps_it_in_the_saved_file() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (pic, out) = (dir.join("pic.png"), dir.join("p.docx"));
|
||||
std::fs::write(&pic, PNG).unwrap();
|
||||
let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy()));
|
||||
let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#];
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.args(["run", "--template", "blank"]);
|
||||
for c in cmds {
|
||||
cli.args(["--cmd", c]);
|
||||
}
|
||||
let run = cli.arg("--save").arg(&out).output().unwrap();
|
||||
let docx = std::fs::read(&out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr));
|
||||
assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! #866: `wordcraft-cli text` omits tracked deletions (it does not go through `document.text`).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_omits_tracked_deletions() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-866-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("t.docx");
|
||||
let path_s = path.to_str().unwrap();
|
||||
|
||||
let make = cli()
|
||||
.args([
|
||||
"run",
|
||||
"--save",
|
||||
path_s,
|
||||
"--cmd",
|
||||
r#"text.insert={"text":"Signed by Alice Smith, Partner."}"#,
|
||||
"--cmd",
|
||||
r#"review.trackChanges={"value":true}"#,
|
||||
"--cmd",
|
||||
r#"select.text={"text":"Alice Smith, Partner"}"#,
|
||||
"--cmd",
|
||||
r#"text.insert={"text":"Bob Jones, Director"}"#,
|
||||
])
|
||||
.output()
|
||||
.expect("run");
|
||||
assert!(make.status.success(), "run failed: {}", String::from_utf8_lossy(&make.stderr));
|
||||
|
||||
let out = cli().args(["text", path_s]).output().expect("text");
|
||||
assert!(out.status.success(), "text failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim_end(), "Signed by Bob Jones, Director.");
|
||||
assert!(!stdout.contains("Alice Smith"), "{stdout}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
|
||||
//! web runner.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
@@ -60,7 +60,8 @@ pub fn start() {
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
@@ -68,7 +69,7 @@ pub fn start() {
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
Ok(Box::new(WebShell { app, inbox, messages }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -90,6 +91,7 @@ fn query() -> String {
|
||||
struct WebShell {
|
||||
app: WordApp,
|
||||
inbox: Inbox,
|
||||
messages: Rc<RefCell<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -109,6 +111,10 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
|
||||
for msg in msgs {
|
||||
self.app.status(msg);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
@@ -169,9 +175,11 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
|
||||
}
|
||||
|
||||
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves, messages: Rc<RefCell<Vec<String>>>) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
let font_ctx = ctx.clone();
|
||||
let font_messages = messages.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
let inbox = open_inbox.clone();
|
||||
@@ -204,10 +212,112 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Asyn
|
||||
animations_enabled: Some(animations_enabled()),
|
||||
store_font: Some(Box::new(font_store::store)),
|
||||
forget_font: Some(Box::new(font_store::forget)),
|
||||
load_system_fonts: Some(Box::new(move || {
|
||||
load_system_fonts(font_ctx.clone(), font_messages.clone());
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
const MAX_SYSTEM_FONT_BYTES: usize = 256 << 20;
|
||||
/// Most local fonts looked at, installed or not (already available, unreadable, too big).
|
||||
const MAX_SYSTEM_FONTS_TRIED: u32 = 4000;
|
||||
|
||||
/// Query the browser's Local Font Access API (`window.queryLocalFonts()`) and install the faces
|
||||
/// into the global font database, up to [`MAX_SYSTEM_FONTS`] faces and [`MAX_SYSTEM_FONT_BYTES`].
|
||||
fn load_system_fonts(ctx: egui::Context, messages: Rc<RefCell<Vec<String>>>) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let query_fn = match js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("queryLocalFonts")) {
|
||||
Ok(val) if val.is_function() => js_sys::Function::from(val),
|
||||
_ => {
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access is not supported by your browser.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fonts_promise = match query_fn.call0(&window) {
|
||||
Ok(p) => js_sys::Promise::from(p),
|
||||
Err(e) => {
|
||||
log::warn!("queryLocalFonts call failed: {e:?}");
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fonts_val = match wasm_bindgen_futures::JsFuture::from(fonts_promise).await {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
log::warn!("queryLocalFonts promise rejected: {e:?}");
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let font_list = js_sys::Array::from(&fonts_val);
|
||||
let len = font_list.length();
|
||||
let mut loaded = 0usize;
|
||||
let mut total_bytes = 0usize;
|
||||
for i in 0..len.min(MAX_SYSTEM_FONTS_TRIED) {
|
||||
if loaded >= MAX_SYSTEM_FONTS || total_bytes >= MAX_SYSTEM_FONT_BYTES {
|
||||
log::warn!("local fonts: stopped after {loaded} fonts ({total_bytes} bytes)");
|
||||
break;
|
||||
}
|
||||
let font_data = font_list.get(i);
|
||||
let Ok(blob_fn) = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("blob")) else {
|
||||
continue;
|
||||
};
|
||||
if !blob_fn.is_function() {
|
||||
continue;
|
||||
}
|
||||
let Ok(blob_val) = js_sys::Function::from(blob_fn).call0(&font_data) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(blob_js) = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(blob_val)).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(blob) = blob_js.dyn_into::<web_sys::Blob>() else {
|
||||
continue;
|
||||
};
|
||||
// Skip a face too big on its own or for what is left of the budget, before reading it.
|
||||
let size = blob.size().clamp(0.0, usize::MAX as f64) as usize;
|
||||
if size > wordcraft_fonts::MAX_FONT_FILE_BYTES || total_bytes.saturating_add(size) > MAX_SYSTEM_FONT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(array_buffer) = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await else {
|
||||
continue;
|
||||
};
|
||||
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
|
||||
if bytes.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES {
|
||||
continue;
|
||||
}
|
||||
total_bytes = total_bytes.saturating_add(bytes.len());
|
||||
let ps_name = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("postscriptName"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_else(|| format!("local-font-{i}"));
|
||||
let name = format!("{ps_name}.ttf");
|
||||
match wordcraft_fonts::FontDb::global().install_font(&name, &bytes) {
|
||||
Ok(_) => loaded += 1,
|
||||
Err(wordcraft_fonts::UserFontError::AlreadyAvailable(_)) => {}
|
||||
Err(e) => log::warn!("local font {name} skipped: {e}"),
|
||||
}
|
||||
}
|
||||
if loaded > 0 {
|
||||
let msg = wordcraft_ui_egui::i18n::fmt(wordcraft_ui_egui::i18n::t("Loaded {count} system fonts."), &[("count", &loaded.to_string())]);
|
||||
messages.borrow_mut().push(msg);
|
||||
} else {
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("All system fonts are already available.").to_string());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the system has animation effects on, as the browser passes it on: off while the
|
||||
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
|
||||
/// query is made once and asked every frame; it follows the system's setting by itself.
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn open() -> Result<IdbDatabase, String> {
|
||||
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
|
||||
let upgrade_req = req.clone();
|
||||
let upgrade = Closure::<dyn FnMut()>::new(move || {
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().map_err(JsValue::from))
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>())
|
||||
&& !db.object_store_names().contains(STORE)
|
||||
&& let Err(e) = db.create_object_store(STORE)
|
||||
{
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//!
|
||||
//! Everything read here comes from other programs: sizes are capped as it is read.
|
||||
//!
|
||||
//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text
|
||||
//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
|
||||
//! Written here: a copy puts HTML beside its text, so other programs keep its formatting (#1523),
|
||||
//! with equations as MathML (#816; pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
|
||||
//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML"
|
||||
//! with OMML, #1297) it can't, so they aren't offered.
|
||||
|
||||
|
||||
@@ -282,6 +282,40 @@ fn build_sha() -> &'static str {
|
||||
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
|
||||
}
|
||||
|
||||
/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1
|
||||
/// one (#1440). A `--control` port that isn't is an error.
|
||||
fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
let (mut args, mut files) = (Vec::new(), Vec::new());
|
||||
for (i, a) in std::env::args_os().skip(1).enumerate() {
|
||||
match a.into_string() {
|
||||
Ok(a) => args.push(a),
|
||||
Err(a) if args.last().is_some_and(|p| p == "--control") => {
|
||||
// Unlike `eprintln!`, never panics.
|
||||
let (n, lossy) = (i + 1, a.to_string_lossy());
|
||||
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));
|
||||
std::process::exit(2);
|
||||
}
|
||||
Err(a) => files.push(a.into()),
|
||||
}
|
||||
}
|
||||
(args, files)
|
||||
}
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = std::fs::read(path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
match opened {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("{name}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
@@ -289,8 +323,9 @@ fn main() -> eframe::Result {
|
||||
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let (args, other_files) = text_args();
|
||||
let mut bad_control: Option<Option<String>> = None;
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
// A flag after `--control` stays a flag; a bad or missing port is an error (#1426).
|
||||
@@ -421,7 +456,7 @@ fn main() -> eframe::Result {
|
||||
// Nothing to open (no files, no sample, no control channel driving the editor): start
|
||||
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
|
||||
// macOS hands over later open on top of it and close it (`apple_events`).
|
||||
if files.is_empty() && !sample && control_port.is_none() {
|
||||
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
|
||||
app.start_screen();
|
||||
}
|
||||
for f in files {
|
||||
@@ -429,6 +464,9 @@ fn main() -> eframe::Result {
|
||||
log::warn!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
open_non_unicode(&mut app, &f);
|
||||
}
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
// With nothing restored, the window the system placed is checked all the same.
|
||||
@@ -442,8 +480,40 @@ fn main() -> eframe::Result {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
#[test]
|
||||
fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));
|
||||
std::fs::write(&path, "Bonjour").unwrap();
|
||||
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
open_non_unicode(&mut app, &path);
|
||||
assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
|
||||
app.run("caret.docEnd", serde_json::json!({})).unwrap();
|
||||
app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();
|
||||
app.run("file.save", serde_json::json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");
|
||||
|
||||
// A file that can't be read is a warning, as one that fails to open by name is.
|
||||
open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_tests {
|
||||
use super::write_prefs;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn finish_pending_jobs() {
|
||||
}
|
||||
|
||||
pub fn submit() -> Submit {
|
||||
Box::new(|bytes, settings: Settings, collate| {
|
||||
Box::new(|bytes, settings: Settings, collate, options| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
|
||||
let job = craft_print::Job {
|
||||
@@ -41,7 +41,7 @@ pub fn submit() -> Submit {
|
||||
grayscale: settings.grayscale,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: "WordCraft document".into(),
|
||||
..Default::default()
|
||||
options,
|
||||
};
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn app(args: &[OsString]) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("WORDCRAFT_CONTROL_PORT")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// The file opens once the window is up (`open_non_unicode`, unit-tested in `main.rs`). Not on
|
||||
/// macOS, where the app opens its window without a display variable and so keeps running.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn a_file_with_such_a_name_starts_the_app() {
|
||||
let out = app(&["--sample".into(), OsString::from_vec(b"caf\xe9.docx".to_vec())]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!stderr.contains("panic"), "stderr: {stderr}");
|
||||
// It went on to open the window, which fails without a display.
|
||||
assert!(stderr.contains("WinitEventLoop"), "stderr: {stderr}");
|
||||
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_isnt_unicode_is_an_error_naming_it() {
|
||||
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
|
||||
assert_eq!(stderr, "wordcraft: argument 3 is not valid Unicode: 79\u{FFFD}81\n");
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
|
||||
[people.lokinmodar]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -60,6 +64,8 @@ display_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
[people.DevCoderMax]
|
||||
real_name = "Gleyson do Nascimento Carvalho"
|
||||
display_name = "Gleyson Carvalho"
|
||||
@@ -85,15 +91,22 @@ display_name = "cyril265"
|
||||
display_name = "Jason Brashear"
|
||||
sync_github_name = false
|
||||
|
||||
|
||||
[people.Losminek]
|
||||
[people.lupant]
|
||||
sync_github_name = true
|
||||
[people.benandez]
|
||||
real_name = "Martin Ball"
|
||||
display_name = "Martin Ball"
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
[people.Losminek]
|
||||
[people.Nghiathan13]
|
||||
|
||||
[people.leoncral]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ms-youssef]
|
||||
sync_github_name = true
|
||||
|
||||
[people.finwarman]
|
||||
real_name = "Fin Warman"
|
||||
|
||||
|
||||
[people.albayazid]
|
||||
real_name = "Sazid Al Bayazid"
|
||||
|
||||
@@ -106,3 +119,19 @@ display_name = "The Legend Of MTS Dev"
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
|
||||
[people.rbr48]
|
||||
real_name = "M S Rana"
|
||||
display_name = "Izhaan Intellect"
|
||||
|
||||
|
||||
|
||||
[people.holmbom]
|
||||
real_name = "Erik Holmbom"
|
||||
display_name = "Erik Holmbom"
|
||||
sync_github_name = false
|
||||
|
||||
[people.andrejskvorc]
|
||||
real_name = "Andrej Škvorc"
|
||||
display_name = "Andrej Škvorc"
|
||||
sync_github_name = false
|
||||
@@ -100,6 +100,8 @@ pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
|
||||
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
|
||||
}
|
||||
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
|
||||
// The corners, the legs' midpoints and the hypotenuse's.
|
||||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
|
||||
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
|
||||
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
|
||||
}
|
||||
|
||||
+30
-21
@@ -63,37 +63,46 @@ const MAX_BOX_DEPTH: usize = 8;
|
||||
/// Most text box stories one paste may create (hostile documents can fan out).
|
||||
const MAX_PASTED_BOXES: usize = 4096;
|
||||
|
||||
/// Call `f` on every paragraph of a block (tables descended, depth-limited).
|
||||
pub(crate) fn each_para<'a>(b: &'a Block, depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||
for cb in &cell.blocks {
|
||||
each_para(cb, depth + 1, f);
|
||||
}
|
||||
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
|
||||
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
|
||||
/// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it).
|
||||
pub(crate) fn each_para<'a>(b: &'a Block, _depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
|
||||
// Most blocks are paragraphs, which need no stack.
|
||||
if let Block::Para(p) = b {
|
||||
return f(p);
|
||||
}
|
||||
let mut todo = vec![b];
|
||||
while let Some(b) = todo.pop() {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) => {
|
||||
// Last first, so they come off the stack in document order.
|
||||
let cells = t.rows.iter().rev().flat_map(|r| r.cells.iter().rev());
|
||||
todo.extend(cells.flat_map(|c| c.blocks.iter().rev()).map(|cb| &**cb));
|
||||
}
|
||||
}
|
||||
Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) if depth < 16 => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
for cb in &mut cell.blocks {
|
||||
each_para_mut(Arc::make_mut(cb), depth + 1, f);
|
||||
}
|
||||
/// [`each_para`], changing the paragraphs.
|
||||
pub(crate) fn each_para_mut(b: &mut Block, _depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
|
||||
if let Block::Para(p) = b {
|
||||
return f(p);
|
||||
}
|
||||
let mut todo = vec![b];
|
||||
while let Some(b) = todo.pop() {
|
||||
match b {
|
||||
Block::Para(p) => f(p),
|
||||
Block::Table(t) => {
|
||||
let cells = t.rows.iter_mut().rev().flat_map(|r| r.cells.iter_mut().rev());
|
||||
todo.extend(cells.flat_map(|c| c.blocks.iter_mut().rev()).map(Arc::make_mut));
|
||||
}
|
||||
}
|
||||
Block::Table(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
|
||||
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
|
||||
/// `id` (tables to 16 levels deep). Table styles are the engine's `table.deleteStyle`'s job.
|
||||
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
|
||||
let is = |s: &Option<String>| s.as_deref() == Some(id);
|
||||
match b {
|
||||
@@ -584,7 +593,7 @@ impl Document {
|
||||
let body = crate::TextBody { next: None, ..src.body };
|
||||
*budget -= 1;
|
||||
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
|
||||
let id = self.add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect());
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
|
||||
if let Some(p) = self.parts.get_mut(&id) {
|
||||
p.body = body;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
|
||||
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai).
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman
|
||||
//! (10000, RTF `\mac`).
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
@@ -125,6 +126,18 @@ const CP1258: [u16; 128] = [
|
||||
0x20AB, 0x00FF,
|
||||
];
|
||||
|
||||
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
|
||||
/// 0xDB (Mac OS 8.5 and later).
|
||||
const MAC_ROMAN: [u16; 128] = [
|
||||
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
|
||||
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
|
||||
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
|
||||
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
|
||||
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
|
||||
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
|
||||
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
|
||||
0x02DB, 0x02C7,
|
||||
];
|
||||
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
|
||||
/// mapping file).
|
||||
const CP437: [u16; 128] = [
|
||||
@@ -236,8 +249,11 @@ pub fn oem_byte(code_page: u16, b: u8) -> char {
|
||||
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
|
||||
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
|
||||
|
||||
/// One byte of a single-byte Windows code page as a char; a code page outside
|
||||
/// [`SINGLE_BYTE_CODE_PAGES`] reads as Windows-1252.
|
||||
/// Mac OS Roman, RTF's `\mac` character set.
|
||||
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
|
||||
|
||||
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
|
||||
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
|
||||
pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
let table: &[u16; 128] = match code_page {
|
||||
874 => &CP874,
|
||||
@@ -249,6 +265,7 @@ pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
1256 => &CP1256,
|
||||
1257 => &CP1257,
|
||||
1258 => &CP1258,
|
||||
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
|
||||
_ => return cp1252(b),
|
||||
};
|
||||
if b < 0x80 {
|
||||
@@ -257,11 +274,25 @@ pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
|
||||
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
|
||||
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
|
||||
pub fn is_byte_code_page(code_page: u16) -> bool {
|
||||
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
|
||||
}
|
||||
|
||||
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
|
||||
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
|
||||
pub fn code_page_byte(code_page: u16, b: u8) -> char {
|
||||
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250… `None` for the ANSI and default charsets (the document's code page applies),
|
||||
/// symbol fonts and the double-byte East Asian charsets.
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC → 437… `None` for the ANSI and default charsets (the
|
||||
/// document's code page applies), symbol fonts and the double-byte East Asian charsets.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
77 => MAC_ROMAN_CODE_PAGE,
|
||||
254 => 437,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
@@ -288,6 +319,10 @@ mod tests {
|
||||
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
|
||||
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
|
||||
assert_eq!(decode(9999, &[0xE9]), "é");
|
||||
// Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes.
|
||||
assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A");
|
||||
assert_eq!(charset_code_page(77), Some(10000));
|
||||
assert_eq!(charset_code_page(254), Some(437));
|
||||
assert_eq!(charset_code_page(204), Some(1251));
|
||||
assert_eq!(charset_code_page(0), None);
|
||||
for cp in SINGLE_BYTE_CODE_PAGES {
|
||||
@@ -297,6 +332,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
|
||||
#[test]
|
||||
fn document_text_in_windows_dos_and_mac_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII");
|
||||
assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ");
|
||||
assert_eq!(decode(10000, &[0x8E]), "é");
|
||||
assert_eq!(decode(1251, &[0xC4]), "Д");
|
||||
for cp in [437, 850, 866, 1250, 1252, 10000] {
|
||||
assert!(is_byte_code_page(cp), "{cp}");
|
||||
}
|
||||
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oem_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
|
||||
|
||||
@@ -45,7 +45,7 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
let path = Path(prefix.clone());
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::FieldStart { instr, locked }) => {
|
||||
Some(InlineObject::FieldStart { instr, locked, .. }) => {
|
||||
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
|
||||
}
|
||||
Some(InlineObject::FieldEnd) => match open.pop() {
|
||||
|
||||
+80
-10
@@ -71,6 +71,27 @@ pub enum DocError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
|
||||
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
|
||||
/// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken.
|
||||
pub fn free_id(largest: Option<u32>, used: impl Iterator<Item = u32>, min: u32) -> Option<u32> {
|
||||
let Some(largest) = largest else { return Some(min) };
|
||||
if let Some(next) = largest.checked_add(1) {
|
||||
return Some(next.max(min));
|
||||
}
|
||||
let mut ids: Vec<u32> = used.filter(|&i| i >= min).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let mut want = min;
|
||||
for i in ids {
|
||||
if i > want {
|
||||
break;
|
||||
}
|
||||
want = want.checked_add(1)?;
|
||||
}
|
||||
Some(want)
|
||||
}
|
||||
|
||||
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -473,6 +494,14 @@ pub struct Settings {
|
||||
/// 2013 and later, which places a table's border at the margin rather than its text and lets
|
||||
/// justified lines shrink their spaces to fit more text.
|
||||
pub compat_mode: u32,
|
||||
/// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word
|
||||
/// draws one that isn't installed in a serif face.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub roman_fonts: Vec<String>,
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space
|
||||
/// after plus the second's space before, not the larger of the two.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
|
||||
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
|
||||
pub allow_space_of_same_style_in_table: bool,
|
||||
@@ -607,6 +636,8 @@ impl Default for Settings {
|
||||
protect_formatting: false,
|
||||
protect_hash: Vec::new(),
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
roman_fonts: Vec::new(),
|
||||
sum_paragraph_spacing: false,
|
||||
allow_space_of_same_style_in_table: false,
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
@@ -863,19 +894,24 @@ impl Document {
|
||||
|
||||
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
|
||||
pub fn plain_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, false)
|
||||
self.story_text(s, TextMode::Plain)
|
||||
}
|
||||
|
||||
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
|
||||
/// Unicode equivalents ([`Paragraph::shown_text`]).
|
||||
pub fn shown_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, true)
|
||||
self.story_text(s, TextMode::Shown)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, shown: bool) -> String {
|
||||
/// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`.
|
||||
pub fn final_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, TextMode::Final)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, mode: TextMode) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
text_of(bl, &mut out, 0, shown);
|
||||
text_of(bl, &mut out, 0, mode);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
@@ -942,15 +978,22 @@ impl Document {
|
||||
&mut self.last_section
|
||||
}
|
||||
|
||||
/// Allocate a new part id and insert the part.
|
||||
/// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more
|
||||
/// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part.
|
||||
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
|
||||
let id = self.parts.keys().next_back().map(|k| k + 1).unwrap_or(1);
|
||||
self.try_add_part(kind, blocks).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// [`Document::add_part`], or an error, adding nothing, when every part id is taken.
|
||||
pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result<u32> {
|
||||
let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1)
|
||||
.ok_or_else(|| DocError::Invalid("no free part id".into()))?;
|
||||
let mut part = Part { kind, blocks, body: TextBody::default() };
|
||||
if part.blocks.is_empty() {
|
||||
part.blocks.push(para_block(Paragraph::new()));
|
||||
}
|
||||
self.parts.insert(id, part);
|
||||
id
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
|
||||
@@ -1207,6 +1250,18 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
|
||||
/// JSON, whose run lengths may overflow, fall short or split a character.
|
||||
pub fn normalize_runs(&mut self) {
|
||||
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
|
||||
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
|
||||
for blocks in stories {
|
||||
for b in blocks.iter_mut() {
|
||||
edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of paragraphs in the body (all depths).
|
||||
pub fn paragraph_count(&self) -> usize {
|
||||
self.para_paths(StoryRef::Body).len()
|
||||
@@ -1356,14 +1411,29 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
/// Which text of a paragraph [`text_of`] collects.
|
||||
#[derive(Clone, Copy)]
|
||||
enum TextMode {
|
||||
/// Every run, tracked deletions included ([`Paragraph::plain_text`]).
|
||||
Plain,
|
||||
/// As shown on screen ([`Paragraph::shown_text`]).
|
||||
Shown,
|
||||
/// Without tracked deletions ([`Paragraph::final_text`]).
|
||||
Final,
|
||||
}
|
||||
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
out.push_str(&if shown { p.shown_text() } else { p.plain_text() });
|
||||
out.push_str(&match mode {
|
||||
TextMode::Plain => p.plain_text(),
|
||||
TextMode::Shown => p.shown_text(),
|
||||
TextMode::Final => p.final_text(),
|
||||
});
|
||||
out.push('\n');
|
||||
}
|
||||
Block::Table(t) => {
|
||||
@@ -1371,7 +1441,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1, shown);
|
||||
text_of(&c.blocks, &mut s, depth + 1, mode);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
|
||||
+49
-7
@@ -499,6 +499,21 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A script or limit operand. The parser reads a script as one letter or a number (`x_10`), so a
|
||||
/// longer run is grouped: `V_(dd)`, not `V_dd` (which reads back as V_d then d).
|
||||
fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
|
||||
if let [MNode::Run(r)] = a
|
||||
&& r.text.chars().nth(1).is_some()
|
||||
&& !(r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_ascii_digit() || c == '.'))
|
||||
{
|
||||
s.push('(');
|
||||
s.push_str(&r.text);
|
||||
s.push(')');
|
||||
} else {
|
||||
lin_operand(a, s, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => s.push_str(&r.text),
|
||||
@@ -515,9 +530,9 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
MNode::Script { kind, base, sub, sup } => {
|
||||
if *kind == ScriptKind::Pre {
|
||||
s.push('_');
|
||||
lin_operand(sub, s, depth);
|
||||
lin_script(sub, s, depth);
|
||||
s.push('^');
|
||||
lin_operand(sup, s, depth);
|
||||
lin_script(sup, s, depth);
|
||||
s.push('▒');
|
||||
lin_operand(base, s, depth);
|
||||
return;
|
||||
@@ -525,11 +540,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
lin_operand(base, s, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
s.push('_');
|
||||
lin_operand(sub, s, depth);
|
||||
lin_script(sub, s, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
s.push('^');
|
||||
lin_operand(sup, s, depth);
|
||||
lin_script(sup, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Rad { deg, deg_hide, e } => {
|
||||
@@ -548,11 +563,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
s.push(*chr);
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
s.push('_');
|
||||
lin_operand(sub, s, depth);
|
||||
lin_script(sub, s, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
s.push('^');
|
||||
lin_operand(sup, s, depth);
|
||||
lin_script(sup, s, depth);
|
||||
}
|
||||
s.push('▒');
|
||||
lin_operand(e, s, depth);
|
||||
@@ -592,7 +607,7 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
MNode::Lim { upper, e, lim } => {
|
||||
lin_operand(e, s, depth);
|
||||
s.push(if *upper { '┴' } else { '┬' });
|
||||
lin_operand(lim, s, depth);
|
||||
lin_script(lim, s, depth);
|
||||
}
|
||||
MNode::Acc { chr, e } => {
|
||||
lin_operand(e, s, depth);
|
||||
@@ -945,6 +960,33 @@ mod tests {
|
||||
assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_letter_scripts_round_trip() {
|
||||
// A script reads one letter or a number, so `V_dd` is V_d then d: longer scripts are grouped.
|
||||
let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) };
|
||||
for n in [
|
||||
vec![script(ScriptKind::Sub, "dd", "")],
|
||||
vec![script(ScriptKind::Sup, "", "ab")],
|
||||
vec![script(ScriptKind::SubSup, "in", "2")],
|
||||
vec![MNode::Nary {
|
||||
chr: '∑',
|
||||
lim_loc: None,
|
||||
grow: false,
|
||||
sub_hide: false,
|
||||
sup_hide: false,
|
||||
sub: vec![run("ij")],
|
||||
sup: vec![run("n")],
|
||||
e: vec![run("x")],
|
||||
}],
|
||||
vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }],
|
||||
] {
|
||||
assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n));
|
||||
}
|
||||
// Numbers and single letters stay bare.
|
||||
assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5");
|
||||
assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_linear_never_panics() {
|
||||
for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
|
||||
|
||||
@@ -1199,6 +1199,21 @@ mod tests {
|
||||
assert!(matches!(eq[0], MNode::Frac { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_up_keeps_a_multi_letter_subscript() {
|
||||
// #1609: `dd` typed in a subscript box split into V_d and d on the next space.
|
||||
let mut eq: Arg = Vec::new();
|
||||
let p = insert_nodes(&mut eq, &MathPos::default(), parse_linear("⬚_⬚"));
|
||||
let p = insert_text(&mut eq, &p, "V", TypeStyle::default());
|
||||
let p = move_right(&eq, &p).unwrap();
|
||||
let p = insert_text(&mut eq, &p, "dd", TypeStyle::default());
|
||||
let p = move_right(&eq, &p).unwrap();
|
||||
assert_eq!(p, MathPos::new(vec![], 1));
|
||||
let built = eq.clone();
|
||||
assert_eq!(build_up(&mut eq, &p, false), None);
|
||||
assert_eq!(eq, built, "{}", lin(&eq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_dissolves_and_removes() {
|
||||
let mut eq = parse_linear("√x");
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, CharProps};
|
||||
use crate::section::NumFormat;
|
||||
use crate::{DocError, free_id};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -230,20 +231,36 @@ impl Numbering {
|
||||
}
|
||||
self.abstract_of(num)?.levels.get(level as usize)
|
||||
}
|
||||
/// Create a new list of `kind`; returns the `num` id.
|
||||
/// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX`
|
||||
/// lists, more than memory holds; then nothing is added and the id is 0, "no list".
|
||||
pub fn add_list(&mut self, kind: ListKind) -> u32 {
|
||||
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
|
||||
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind), ..Default::default() });
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
nid
|
||||
self.try_add_list(kind).unwrap_or(0)
|
||||
}
|
||||
/// A new `num` that restarts numbering of an existing list.
|
||||
/// [`Numbering::add_list`], or an error, adding nothing, when list ids are all taken.
|
||||
pub fn try_add_list(&mut self, kind: ListKind) -> crate::Result<u32> {
|
||||
self.try_add_levels(None, levels_for(kind))
|
||||
}
|
||||
/// A new list definition (`name`, `levels`) and a `num` for it; returns the `num` id. Both ids
|
||||
/// are [`free_id`]s, so the largest ids a file can hold don't make them collide with its lists;
|
||||
/// an error, adding nothing, when they are all taken.
|
||||
pub fn try_add_levels(&mut self, name: Option<String>, levels: Vec<Level>) -> crate::Result<u32> {
|
||||
let aid = free_id(self.abstracts.iter().map(|a| a.id).max(), self.abstracts.iter().map(|a| a.id), 0);
|
||||
let (Some(aid), Some(nid)) = (aid, self.free_num_id()) else { return Err(DocError::Invalid("no free list id".into())) };
|
||||
self.abstracts.push(AbstractNum { id: aid, name, levels, ..Default::default() });
|
||||
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
Ok(nid)
|
||||
}
|
||||
/// A `num` id no list has (`num` 0 means "no list"), `None` when they are all taken.
|
||||
fn free_num_id(&self) -> Option<u32> {
|
||||
free_id(self.nums.iter().map(|n| n.id).max(), self.nums.iter().map(|n| n.id), 1)
|
||||
}
|
||||
/// A new `num` that restarts numbering of an existing list (`None`: no such list, or list ids
|
||||
/// are all taken).
|
||||
pub fn restart(&mut self, num: u32) -> Option<u32> {
|
||||
let source = self.num(num)?;
|
||||
let a = source.abstract_id;
|
||||
let level_overrides = source.level_overrides.clone();
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
let nid = self.free_num_id()?;
|
||||
// Every level starts again at its own effective start, so a level this list defines itself keeps its start.
|
||||
let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len());
|
||||
let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect();
|
||||
|
||||
+32
-20
@@ -288,7 +288,11 @@ pub enum ShapeKind {
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Ellipse,
|
||||
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
|
||||
FlowchartOr,
|
||||
Triangle,
|
||||
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
|
||||
RightTriangle,
|
||||
Diamond,
|
||||
Line,
|
||||
Arrow,
|
||||
@@ -444,6 +448,11 @@ pub enum InlineObject {
|
||||
result: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
|
||||
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
|
||||
/// saves; cleared when WordCraft computes a result, like Word's F9.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
|
||||
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
|
||||
/// toggle: never written to files.
|
||||
@@ -462,6 +471,9 @@ pub enum InlineObject {
|
||||
instr: String,
|
||||
#[serde(default)]
|
||||
locked: bool,
|
||||
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
dirty: bool,
|
||||
},
|
||||
/// The end of the innermost open [`InlineObject::FieldStart`].
|
||||
FieldEnd,
|
||||
@@ -1033,32 +1045,32 @@ impl Paragraph {
|
||||
|
||||
/// Merge adjacent equal runs and drop empty ones.
|
||||
pub fn normalize(&mut self) {
|
||||
// Runs must end on character boundaries and cover the text exactly (never expected, but
|
||||
// files lie): clamp each end to the text, push it past a split character, pad the tail.
|
||||
let n = self.text.len();
|
||||
let mut end = 0usize;
|
||||
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
|
||||
for r in self.runs.drain(..) {
|
||||
if r.len == 0 {
|
||||
let mut e = end.saturating_add(r.len).min(n);
|
||||
while !self.text.is_char_boundary(e) {
|
||||
e += 1;
|
||||
}
|
||||
if e == end {
|
||||
continue;
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.props == r.props => l.len += r.len,
|
||||
_ => out.push(r),
|
||||
Some(l) if l.props == r.props => l.len += e - end,
|
||||
_ => out.push(Run { len: e - end, props: r.props }),
|
||||
}
|
||||
end = e;
|
||||
}
|
||||
if end < n {
|
||||
match out.last_mut() {
|
||||
Some(l) => l.len += n - end,
|
||||
None => out.push(Run { len: n, props: self.mark.clone() }),
|
||||
}
|
||||
}
|
||||
self.runs = out;
|
||||
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
|
||||
let total: usize = self.runs.iter().map(|r| r.len).sum();
|
||||
if total != self.text.len() {
|
||||
if total < self.text.len() {
|
||||
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
|
||||
self.runs.push(Run { len: self.text.len() - total, props: p });
|
||||
} else {
|
||||
let mut keep = self.text.len();
|
||||
for r in &mut self.runs {
|
||||
r.len = r.len.min(keep);
|
||||
keep -= r.len;
|
||||
}
|
||||
self.runs.retain(|r| r.len > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete `a..b` (bytes). Objects inside are removed.
|
||||
@@ -1351,7 +1363,7 @@ mod tests {
|
||||
let mut p = Paragraph::with_text("añb€c", CharProps::default());
|
||||
p.insert_object(
|
||||
3,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1425,7 +1437,7 @@ mod tests {
|
||||
let mut p = Paragraph::with_text("ab", CharProps::default());
|
||||
p.insert_object(
|
||||
1,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
+15
-1
@@ -554,6 +554,10 @@ impl TabLeader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this
|
||||
/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it.
|
||||
pub const TAB_POS_EPS: f32 = 0.5;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TabStop {
|
||||
/// Points from the paragraph's left indent origin (the text column's left edge).
|
||||
@@ -1074,6 +1078,10 @@ pub struct RowProps {
|
||||
pub header: bool,
|
||||
/// Don't let the row break across pages.
|
||||
pub cant_split: bool,
|
||||
/// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment,
|
||||
/// indent, borders, shading, layout and cell margins. `Some` when the file has the element.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exceptions: Option<Box<TableProps>>,
|
||||
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
|
||||
/// width of that gap in points (`w:wBefore`).
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
@@ -1119,11 +1127,17 @@ pub struct CellProps {
|
||||
pub vmerge: VMerge,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
pub valign: VAlign,
|
||||
/// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit
|
||||
/// `Top` beats the style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub valign: Option<VAlign>,
|
||||
/// Cell margins override (top, left, bottom, right), points.
|
||||
pub margins: 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.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub text_direction_set: bool,
|
||||
pub no_wrap: bool,
|
||||
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
|
||||
/// empty cells shrinks to its borders.
|
||||
|
||||
@@ -2,7 +2,36 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Borders, VAlign};
|
||||
use crate::props::Borders;
|
||||
|
||||
/// How a section's text sits on its pages, top to bottom (`w:vAlign`, ECMA-376 §17.6.23,
|
||||
/// ST_VerticalJc). Table cells and text boxes use [`crate::props::VAlign`], which has no
|
||||
/// Justified.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PageVAlign {
|
||||
#[default]
|
||||
Top,
|
||||
Center,
|
||||
/// Justified (`both`): the room left on each page is shared out equally between the page's
|
||||
/// paragraphs and tables, so the text runs from the top margin to the bottom one.
|
||||
#[serde(alias = "both", alias = "justified")]
|
||||
Justify,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl PageVAlign {
|
||||
/// From a command or file value: `top|center|justify|bottom` (also `both`, `justified`,
|
||||
/// `middle`); anything else is Top.
|
||||
pub fn parse(v: &str) -> PageVAlign {
|
||||
match v {
|
||||
"center" | "middle" => PageVAlign::Center,
|
||||
"justify" | "justified" | "both" => PageVAlign::Justify,
|
||||
"bottom" => PageVAlign::Bottom,
|
||||
_ => PageVAlign::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -227,7 +256,7 @@ impl NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
|
||||
/// Vietnamese, Russian, text amounts) show decimal, as do values past a sequence's end
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
use crate::cjk_numbers as cjk;
|
||||
@@ -237,6 +266,8 @@ impl NumFormat {
|
||||
NumFormat::LowerRoman => roman(n),
|
||||
NumFormat::UpperLetter => letters(n).to_uppercase(),
|
||||
NumFormat::LowerLetter => letters(n),
|
||||
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
|
||||
NumFormat::RussianLower => russian_letters(n),
|
||||
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
|
||||
NumFormat::CardinalText => cardinal_text(n),
|
||||
NumFormat::OrdinalText => ordinal_text(n),
|
||||
@@ -275,8 +306,6 @@ impl NumFormat {
|
||||
| NumFormat::DecimalHalfWidth
|
||||
| NumFormat::Hex
|
||||
| NumFormat::VietnameseCounting
|
||||
| NumFormat::RussianLower
|
||||
| NumFormat::RussianUpper
|
||||
| NumFormat::Hebrew1
|
||||
| NumFormat::Hebrew2
|
||||
| NumFormat::ArabicAlpha
|
||||
@@ -354,6 +383,22 @@ pub fn letters(n: u32) -> String {
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
/// The 29 letters Word counts Russian lists with: а..я without ё, й, ъ and ь (ы stays).
|
||||
const RUSSIAN_LETTERS: [char; 29] =
|
||||
['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ы', 'э', 'ю', 'я'];
|
||||
|
||||
/// Word-style Russian letters: а..я, аа..яя, ааа.. (repeated letter, as [`letters`]).
|
||||
pub fn russian_letters(n: u32) -> String {
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let len = RUSSIAN_LETTERS.len() as u32;
|
||||
let n = n.min(len * 30);
|
||||
let k = (n - 1) / len + 1;
|
||||
let c = RUSSIAN_LETTERS.get(((n - 1) % len) as usize).copied().unwrap_or('а');
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"zero",
|
||||
"one",
|
||||
@@ -676,8 +721,16 @@ pub struct SectionProps {
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
|
||||
/// format), so saving writes it back rather than dropping it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub page_num_set: bool,
|
||||
pub line_numbers: Option<LineNumbering>,
|
||||
pub valign: VAlign,
|
||||
pub valign: PageVAlign,
|
||||
/// Form protection of the section (`w:formProt`, ECMA-376 §17.6.6): `Some` when the file
|
||||
/// states it. Kept so saving writes it back; editing does not act on it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_prot: Option<bool>,
|
||||
pub page_borders: Option<Borders>,
|
||||
/// Text direction / bidi section.
|
||||
pub rtl: bool,
|
||||
@@ -685,6 +738,9 @@ pub struct SectionProps {
|
||||
/// sections turn the page's text area (headers and footers stay horizontal).
|
||||
#[serde(skip_serializing_if = "is_horizontal")]
|
||||
pub text_direction: crate::props::TextDirection,
|
||||
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub text_direction_set: bool,
|
||||
/// Tracked change of the section's properties (`w:sectPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<crate::props::PropChange<SectionProps>>>,
|
||||
@@ -718,11 +774,14 @@ impl Default for SectionProps {
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
page_num_set: false,
|
||||
line_numbers: None,
|
||||
valign: VAlign::Top,
|
||||
valign: PageVAlign::Top,
|
||||
form_prot: None,
|
||||
page_borders: None,
|
||||
rtl: false,
|
||||
text_direction: crate::props::TextDirection::Horizontal,
|
||||
text_direction_set: false,
|
||||
fmt_change: None,
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
@@ -831,6 +890,8 @@ mod tests {
|
||||
assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一");
|
||||
assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③");
|
||||
assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal");
|
||||
assert_eq!(NumFormat::RussianLower.format(0), "");
|
||||
assert_eq!(NumFormat::RussianUpper.format(u32::MAX).chars().count(), 30);
|
||||
for f in NumFormat::ALL {
|
||||
assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips");
|
||||
assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}");
|
||||
@@ -838,6 +899,17 @@ mod tests {
|
||||
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_letters_follow_word() {
|
||||
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
|
||||
let labels: Vec<String> = (1..=31).map(|n| NumFormat::RussianLower.format(n)).collect();
|
||||
assert_eq!(labels.join(" "), "а б в г д е ж з и к л м н о п р с т у ф х ц ч ш щ ы э ю я аа бб");
|
||||
assert_eq!(NumFormat::RussianLower.format(59), "ааа");
|
||||
assert_eq!(NumFormat::RussianLower.format(100), "нннн");
|
||||
assert_eq!(NumFormat::RussianUpper.format(26), "Ы");
|
||||
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_swaps() {
|
||||
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
|
||||
|
||||
+285
-1
@@ -3,7 +3,12 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TextColor, Underline};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TableLook, TextColor, Underline,
|
||||
VAlign,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -88,6 +93,166 @@ pub struct TableStyleParts {
|
||||
pub col_band_fill: Option<Rgb>,
|
||||
pub col_band_chr: CharProps,
|
||||
pub col_band_borders: Option<Borders>,
|
||||
/// Per-part formatting the named fields above don't carry: every field of the parts without
|
||||
/// one (even bands, corner cells), and the cell text alignment of every part. Read and write
|
||||
/// parts through [`TableStyleParts::part`] / [`TableStyleParts::set_part`].
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub regions: BTreeMap<TablePart, PartFormat>,
|
||||
}
|
||||
|
||||
/// A conditional formatting part of a table style (`w:tblStylePr/@w:type`, ECMA-376 §17.7.6).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TablePart {
|
||||
WholeTable,
|
||||
FirstRow,
|
||||
LastRow,
|
||||
FirstCol,
|
||||
LastCol,
|
||||
Band1Vert,
|
||||
Band2Vert,
|
||||
Band1Horz,
|
||||
Band2Horz,
|
||||
NeCell,
|
||||
NwCell,
|
||||
SeCell,
|
||||
SwCell,
|
||||
}
|
||||
|
||||
impl TablePart {
|
||||
/// Every part, lowest precedence first (ECMA-376 §17.7.6: whole table, bands, first/last
|
||||
/// column, first/last row, corner cells).
|
||||
pub const ALL: [TablePart; 13] = [
|
||||
TablePart::WholeTable,
|
||||
TablePart::Band1Vert,
|
||||
TablePart::Band2Vert,
|
||||
TablePart::Band1Horz,
|
||||
TablePart::Band2Horz,
|
||||
TablePart::FirstCol,
|
||||
TablePart::LastCol,
|
||||
TablePart::FirstRow,
|
||||
TablePart::LastRow,
|
||||
TablePart::NeCell,
|
||||
TablePart::NwCell,
|
||||
TablePart::SeCell,
|
||||
TablePart::SwCell,
|
||||
];
|
||||
/// The `w:type` value.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
TablePart::WholeTable => "wholeTable",
|
||||
TablePart::FirstRow => "firstRow",
|
||||
TablePart::LastRow => "lastRow",
|
||||
TablePart::FirstCol => "firstCol",
|
||||
TablePart::LastCol => "lastCol",
|
||||
TablePart::Band1Vert => "band1Vert",
|
||||
TablePart::Band2Vert => "band2Vert",
|
||||
TablePart::Band1Horz => "band1Horz",
|
||||
TablePart::Band2Horz => "band2Horz",
|
||||
TablePart::NeCell => "neCell",
|
||||
TablePart::NwCell => "nwCell",
|
||||
TablePart::SeCell => "seCell",
|
||||
TablePart::SwCell => "swCell",
|
||||
}
|
||||
}
|
||||
pub fn from_ooxml(s: &str) -> Option<TablePart> {
|
||||
TablePart::ALL.into_iter().find(|p| p.ooxml() == s)
|
||||
}
|
||||
/// A part spanning whole rows (its left/right edges are the table's sides, `inside_v` the
|
||||
/// edges between its cells).
|
||||
pub fn is_row(self) -> bool {
|
||||
matches!(self, TablePart::FirstRow | TablePart::LastRow | TablePart::Band1Horz | TablePart::Band2Horz)
|
||||
}
|
||||
/// A part spanning whole columns (its top/bottom edges are the table's, `between` the edges
|
||||
/// between its cells).
|
||||
pub fn is_column(self) -> bool {
|
||||
matches!(self, TablePart::FirstCol | TablePart::LastCol | TablePart::Band1Vert | TablePart::Band2Vert)
|
||||
}
|
||||
fn bit(self) -> u16 {
|
||||
1 << (TablePart::ALL.iter().position(|p| *p == self).unwrap_or(0) as u16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The table style parts one cell is in (always the whole table).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct PartSet(u16);
|
||||
|
||||
impl PartSet {
|
||||
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
|
||||
/// in a table with options `look` and row bands of `band_size` rows.
|
||||
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let first_col = look.first_column && g == 0;
|
||||
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
|
||||
let size = band_size.clamp(1, 1000) as usize;
|
||||
let row_band = look.banded_rows && !header;
|
||||
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2);
|
||||
let col_band = look.banded_columns && !first_col;
|
||||
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let mut bits = TablePart::WholeTable.bit();
|
||||
for (on, p) in [
|
||||
(col_band && odd_col, TablePart::Band1Vert),
|
||||
(col_band && !odd_col, TablePart::Band2Vert),
|
||||
(row_band && odd_row, TablePart::Band1Horz),
|
||||
(row_band && !odd_row, TablePart::Band2Horz),
|
||||
(first_col, TablePart::FirstCol),
|
||||
(last_col, TablePart::LastCol),
|
||||
(header, TablePart::FirstRow),
|
||||
(total, TablePart::LastRow),
|
||||
(header && last_col, TablePart::NeCell),
|
||||
(header && first_col, TablePart::NwCell),
|
||||
(total && last_col, TablePart::SeCell),
|
||||
(total && first_col, TablePart::SwCell),
|
||||
] {
|
||||
if on {
|
||||
bits |= p.bit();
|
||||
}
|
||||
}
|
||||
PartSet(bits)
|
||||
}
|
||||
pub fn contains(self, p: TablePart) -> bool {
|
||||
self.0 & p.bit() != 0
|
||||
}
|
||||
/// The parts, lowest precedence first.
|
||||
pub fn iter(self) -> impl Iterator<Item = TablePart> {
|
||||
TablePart::ALL.into_iter().filter(move |p| self.contains(*p))
|
||||
}
|
||||
}
|
||||
|
||||
/// What one table style part gives its cells.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PartFormat {
|
||||
pub chr: CharProps,
|
||||
/// Cell shading.
|
||||
pub fill: Option<Rgb>,
|
||||
/// Cell borders (the whole table's are the table borders).
|
||||
pub borders: Option<Borders>,
|
||||
/// Horizontal alignment of the cells' paragraphs (`w:pPr/w:jc`).
|
||||
pub align: Option<Align>,
|
||||
/// Vertical alignment in the cells (`w:tcPr/w:vAlign`).
|
||||
pub v_align: Option<VAlign>,
|
||||
}
|
||||
|
||||
impl PartFormat {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == PartFormat::default()
|
||||
}
|
||||
/// What `patch` sets wins; borders merge edge by edge.
|
||||
pub fn overlay(&mut self, patch: &PartFormat) {
|
||||
self.chr.overlay(&patch.chr);
|
||||
self.fill = patch.fill.or(self.fill);
|
||||
self.borders = match (self.borders, patch.borders) {
|
||||
(Some(mut b), Some(p)) => {
|
||||
b.overlay(&p);
|
||||
Some(b)
|
||||
}
|
||||
(b, p) => p.or(b),
|
||||
};
|
||||
self.align = patch.align.or(self.align);
|
||||
self.v_align = patch.v_align.or(self.v_align);
|
||||
}
|
||||
}
|
||||
|
||||
impl TableStyleParts {
|
||||
@@ -124,6 +289,125 @@ impl TableStyleParts {
|
||||
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
|
||||
self.col_band_chr.overlay(&patch.col_band_chr);
|
||||
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
|
||||
for (k, f) in &patch.regions {
|
||||
self.regions.entry(*k).or_default().overlay(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Part `p`'s formatting. The whole table's text formatting is the style's own
|
||||
/// ([`Style::table_part`] adds it); its borders are the table borders.
|
||||
pub fn part(&self, p: TablePart) -> PartFormat {
|
||||
let extra = self.regions.get(&p).cloned().unwrap_or_default();
|
||||
let named = |chr: &CharProps, fill: Option<Rgb>, borders: Option<Borders>| PartFormat {
|
||||
chr: chr.clone(),
|
||||
fill,
|
||||
borders,
|
||||
align: extra.align,
|
||||
v_align: extra.v_align,
|
||||
};
|
||||
match p {
|
||||
TablePart::WholeTable => {
|
||||
PartFormat { chr: CharProps::default(), fill: self.fill, borders: self.borders, align: None, v_align: extra.v_align }
|
||||
}
|
||||
TablePart::FirstRow => named(&self.header_chr, self.header_fill, self.header_borders),
|
||||
TablePart::LastRow => {
|
||||
// The built-in top rule is the region's top edge unless its borders set one.
|
||||
let borders = match (self.total_borders, self.total_border_top) {
|
||||
(Some(mut b), top) => {
|
||||
b.top = b.top.or(top);
|
||||
Some(b)
|
||||
}
|
||||
(None, top) => top.map(|t| Borders { top: Some(t), ..Borders::default() }),
|
||||
};
|
||||
named(&self.total_chr, self.total_fill, borders)
|
||||
}
|
||||
TablePart::FirstCol => named(&self.first_col_chr, self.first_col_fill, self.first_col_borders),
|
||||
TablePart::LastCol => named(&self.last_col_chr, self.last_col_fill, self.last_col_borders),
|
||||
TablePart::Band1Vert => named(&self.col_band_chr, self.col_band_fill, self.col_band_borders),
|
||||
TablePart::Band1Horz => named(&self.band_chr, self.band_fill, self.band_borders),
|
||||
_ => extra,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace part `p`'s formatting (for the whole table only its fill, borders and vertical
|
||||
/// alignment: [`Style::set_table_part`] stores the rest).
|
||||
pub fn set_part(&mut self, p: TablePart, f: PartFormat) {
|
||||
let align_only = PartFormat { align: f.align, v_align: f.v_align, ..PartFormat::default() };
|
||||
let PartFormat { chr, fill, borders, .. } = f.clone();
|
||||
let rest = match p {
|
||||
TablePart::WholeTable => {
|
||||
self.fill = fill;
|
||||
self.borders = borders;
|
||||
PartFormat { v_align: f.v_align, ..PartFormat::default() }
|
||||
}
|
||||
TablePart::FirstRow => {
|
||||
(self.header_chr, self.header_fill, self.header_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::LastRow => {
|
||||
(self.total_chr, self.total_fill) = (chr, fill);
|
||||
self.total_border_top = borders.and_then(|b| b.top);
|
||||
self.total_borders = borders.map(|b| Borders { top: None, ..b }).filter(|b| *b != Borders::default());
|
||||
align_only
|
||||
}
|
||||
TablePart::FirstCol => {
|
||||
(self.first_col_chr, self.first_col_fill, self.first_col_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::LastCol => {
|
||||
(self.last_col_chr, self.last_col_fill, self.last_col_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::Band1Vert => {
|
||||
(self.col_band_chr, self.col_band_fill, self.col_band_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
TablePart::Band1Horz => {
|
||||
(self.band_chr, self.band_fill, self.band_borders) = (chr, fill, borders);
|
||||
align_only
|
||||
}
|
||||
_ => f,
|
||||
};
|
||||
if rest.is_empty() {
|
||||
self.regions.remove(&p);
|
||||
} else {
|
||||
self.regions.insert(p, rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// Table style part `p` as this style alone sets it (the whole table's text formatting and
|
||||
/// paragraph alignment are the style's own).
|
||||
pub fn table_part(&self, p: TablePart) -> PartFormat {
|
||||
let mut f = self.table.as_ref().map(|t| t.part(p)).unwrap_or_default();
|
||||
if p == TablePart::WholeTable {
|
||||
f.chr = self.chr.clone();
|
||||
f.align = self.para.align;
|
||||
}
|
||||
f
|
||||
}
|
||||
/// Replace part `p` of this table style.
|
||||
pub fn set_table_part(&mut self, p: TablePart, f: PartFormat) {
|
||||
if p == TablePart::WholeTable {
|
||||
self.chr = f.chr.clone();
|
||||
self.para.align = f.align;
|
||||
}
|
||||
let mut t = self.table.take().unwrap_or_default();
|
||||
t.set_part(p, f);
|
||||
self.table = (t != TableStyleParts::default()).then_some(t);
|
||||
}
|
||||
}
|
||||
|
||||
impl TableStyleProps {
|
||||
/// Part `p` with the based-on chain merged (see [`Style::table_part`]).
|
||||
pub fn part(&self, p: TablePart) -> PartFormat {
|
||||
let mut f = self.parts.part(p);
|
||||
if p == TablePart::WholeTable {
|
||||
f.chr = self.chr.clone();
|
||||
f.align = self.para.align;
|
||||
}
|
||||
f
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+119
-1
@@ -90,6 +90,46 @@ fn parts_and_media() {
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_ids_skip_taken_ones_instead_of_overflowing() {
|
||||
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
|
||||
assert_eq!(free_id(Some(7), [3, 7].into_iter(), 1), Some(8));
|
||||
assert_eq!(free_id(Some(0), [0].into_iter(), 1), Some(1));
|
||||
// A file used the largest id: the lowest free one, not a wrapped 0 or a taken id.
|
||||
assert_eq!(free_id(Some(u32::MAX), [1, 2, 4, u32::MAX].into_iter(), 1), Some(3));
|
||||
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, 1, 0, 1].into_iter(), 0), Some(2));
|
||||
// Every id from `min` up taken: none, rather than a duplicate.
|
||||
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, u32::MAX - 1, u32::MAX - 2].into_iter(), u32::MAX - 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_and_lists_after_the_largest_ids_take_free_ones() {
|
||||
// A file can carry any ids, 0 and u32::MAX included; new ones must not overflow or replace them.
|
||||
let mut d = Document::new();
|
||||
for id in [0, 1, u32::MAX] {
|
||||
let blocks = vec![para_block(Paragraph::with_text(&id.to_string(), CharProps::default()))];
|
||||
d.parts.insert(id, Part { kind: PartKind::Header, blocks, ..Default::default() });
|
||||
}
|
||||
let parts = d.parts.clone();
|
||||
assert_eq!(d.try_add_part(PartKind::Comment, Vec::new()), Ok(2));
|
||||
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
|
||||
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
|
||||
|
||||
let n = &mut d.numbering;
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.abstracts[1].id = u32::MAX;
|
||||
n.nums[1].id = u32::MAX;
|
||||
n.nums[1].abstract_id = u32::MAX;
|
||||
let lists = n.clone();
|
||||
let num = n.try_add_list(ListKind::Numbered).unwrap();
|
||||
assert_eq!((num, n.num(num).map(|x| x.abstract_id)), (2, Some(1)));
|
||||
assert_eq!(n.abstract_of(num).map(|a| &a.levels), Some(&numbering::levels_for(ListKind::Numbered)));
|
||||
assert_eq!(n.restart(num), Some(3));
|
||||
assert_eq!(n.num(3).map(|x| x.abstract_id), Some(1));
|
||||
assert_eq!((&n.abstracts[..2], &n.nums[..2]), (&lists.abstracts[..], &lists.nums[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_is_shallow() {
|
||||
let d = Document::from_text("a\nb");
|
||||
@@ -241,6 +281,84 @@ fn prune_text_boxes_drops_only_unshown_ones() {
|
||||
assert_eq!(d.prune_text_boxes(), 0);
|
||||
}
|
||||
|
||||
/// `blocks` in the only cell of `levels` tables, each nested in the next.
|
||||
fn nested_tables(levels: usize, blocks: Blocks) -> Blocks {
|
||||
(0..levels).fold(blocks, |inner, _| {
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
t.rows[0].cells[0].blocks = inner;
|
||||
vec![Arc::new(Block::Table(t))]
|
||||
})
|
||||
}
|
||||
|
||||
/// A paragraph showing text box story `story` (drawing id `id`).
|
||||
fn box_para(story: u32, id: u32) -> Blocks {
|
||||
let shape = InlineObject::Shape {
|
||||
kind: para::ShapeKind::TextBox,
|
||||
w: 50.0,
|
||||
h: 20.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
float: Default::default(),
|
||||
story: Some(story),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: connector::ShapeExtra { id, ..Default::default() },
|
||||
};
|
||||
let mut p = Paragraph::with_text("cell", CharProps::default());
|
||||
p.insert_object(0, shape, &CharProps::default()).unwrap();
|
||||
vec![para_block(p)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
|
||||
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
|
||||
// for unshown and the next edit pruned its story.
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let mut d = Document::from_text("body");
|
||||
let at_17 = d.add_part(PartKind::TextBox, para_of("17 tables down"));
|
||||
let at_40 = d.add_part(PartKind::TextBox, para_of("40 tables down"));
|
||||
let inner = d.add_part(PartKind::TextBox, para_of("deep in a box"));
|
||||
let outer = d.add_part(PartKind::TextBox, nested_tables(18, box_para(inner, 0)));
|
||||
let in_header = d.add_part(PartKind::TextBox, para_of("deep in a header"));
|
||||
let hdr = d.add_part(PartKind::Header, nested_tables(20, box_para(in_header, 0)));
|
||||
let unshown = d.add_part(PartKind::TextBox, para_of("shown nowhere"));
|
||||
d.body.extend(nested_tables(17, box_para(at_17, 0)));
|
||||
d.body.extend(nested_tables(40, box_para(at_40, 7)));
|
||||
d.body.extend(box_para(outer, 0));
|
||||
assert_eq!(d.prune_text_boxes(), 1);
|
||||
assert!(!d.parts.contains_key(&unshown));
|
||||
let kept: Vec<u32> = d.parts.keys().copied().collect();
|
||||
assert_eq!(kept, vec![at_17, at_40, inner, outer, in_header, hdr]);
|
||||
assert_eq!(d.prune_text_boxes(), 0);
|
||||
// Counted in reading order, the nested box after its own; new drawing ids above the deep one.
|
||||
let part = StoryRef::Part;
|
||||
assert_eq!(d.counted_stories(), vec![StoryRef::Body, part(at_17), part(at_40), part(outer), part(inner)]);
|
||||
assert_eq!(d.next_shape_id(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeply_nested_table_is_copied_with_its_text_boxes() {
|
||||
// A copy takes the box 17 tables down along, so pasting it into another document shows its
|
||||
// text, not the other document's box that happens to have the same id.
|
||||
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
|
||||
let mut d = Document::from_text("body");
|
||||
let deep = d.add_part(PartKind::TextBox, para_of("deep box"));
|
||||
d.body.extend(nested_tables(17, box_para(deep, 0)));
|
||||
let frag = d.body_fragment();
|
||||
assert_eq!(frag.parts.keys().copied().collect::<Vec<_>>(), vec![deep]);
|
||||
let mut e = Document::new();
|
||||
let own = e.add_part(PartKind::TextBox, para_of("its own box"));
|
||||
assert_eq!(own, deep, "the same id in both documents");
|
||||
e.body = box_para(own, 0);
|
||||
let end = e.end_of(StoryRef::Body);
|
||||
e.insert_fragment(&end, &frag).unwrap();
|
||||
let mut shown = Vec::new();
|
||||
e.objects_in_reading_order(&mut |o| shown.extend(o.text_box()));
|
||||
let texts: Vec<String> = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect();
|
||||
assert_eq!(texts, vec!["its own box", "deep box"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_in_text_box_follow_nested_boxes_once() {
|
||||
let shape = |story| InlineObject::Shape {
|
||||
@@ -273,7 +391,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
|
||||
}
|
||||
|
||||
fn fs(instr: &str) -> InlineObject {
|
||||
InlineObject::FieldStart { instr: instr.into(), locked: false }
|
||||
InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -73,7 +73,9 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
|
||||
| ShapeKind::StraightConnector
|
||||
| ShapeKind::ElbowConnector
|
||||
| ShapeKind::CurvedConnector => return None,
|
||||
ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(),
|
||||
ShapeKind::Ellipse | ShapeKind::FlowchartOr => {
|
||||
(0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect()
|
||||
}
|
||||
ShapeKind::RoundedRectangle => {
|
||||
// Corner radius as drawn.
|
||||
let r = crate::connector::corner_radius(corner, w, h);
|
||||
@@ -88,6 +90,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Op
|
||||
pts
|
||||
}
|
||||
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
|
||||
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
|
||||
ShapeKind::Arrow => {
|
||||
// As drawn: the head is half the height long, the shaft 40% of the height thick.
|
||||
@@ -217,6 +220,7 @@ mod tests {
|
||||
ShapeKind::Ellipse,
|
||||
ShapeKind::RoundedRectangle,
|
||||
ShapeKind::Triangle,
|
||||
ShapeKind::RightTriangle,
|
||||
ShapeKind::Diamond,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::Star,
|
||||
|
||||
@@ -37,16 +37,22 @@ fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
|
||||
|
||||
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
|
||||
/// relative to the text box subdocument).
|
||||
///
|
||||
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
|
||||
/// the end of the last one kept (a hostile table pointing many text boxes at the same text,
|
||||
/// each read into its own part) is dropped: every subdocument CP becomes at most one story.
|
||||
pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> {
|
||||
let Some(pair) = pair else { return Vec::new() };
|
||||
let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut kept_to = 0u32;
|
||||
for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() {
|
||||
// The story runs to the next entry's CP (the PLC's last CP for the last entry).
|
||||
let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
|
||||
let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
|
||||
if end > *cp && lid != 0 {
|
||||
if end > *cp && *cp >= kept_to && lid != 0 {
|
||||
out.push((lid, (*cp, end)));
|
||||
kept_to = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -180,7 +186,8 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
2 => ShapeKind::RoundedRectangle,
|
||||
3 => ShapeKind::Ellipse,
|
||||
4 => ShapeKind::Diamond,
|
||||
5 | 6 => ShapeKind::Triangle,
|
||||
5 => ShapeKind::Triangle,
|
||||
6 => ShapeKind::RightTriangle,
|
||||
12 | 187 => ShapeKind::Star,
|
||||
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
|
||||
20 | 32 => ShapeKind::Line,
|
||||
|
||||
@@ -15,6 +15,7 @@ mod fkp;
|
||||
mod fmt;
|
||||
mod list;
|
||||
mod media;
|
||||
mod metadata;
|
||||
mod notes;
|
||||
mod officeart;
|
||||
mod piece;
|
||||
@@ -148,6 +149,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
};
|
||||
|
||||
let mut doc = Document::new();
|
||||
if let Some(core) = metadata::read(&mut comp) {
|
||||
doc.core = core;
|
||||
}
|
||||
doc.body.clear();
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
|
||||
doc.numbering = list::parse(&table, &fib, &fonts);
|
||||
@@ -571,7 +575,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
return;
|
||||
}
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false, form: String::new() });
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1.
|
||||
//! Broken metadata must not prevent opening the document or allocate from unchecked sizes.
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
|
||||
use wordcraft_doc::CoreProps;
|
||||
|
||||
const MAX_STREAM: u64 = 2 << 20;
|
||||
const MAX_STRING: usize = 64 << 10;
|
||||
const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9];
|
||||
|
||||
pub(crate) fn read<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<CoreProps> {
|
||||
let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?;
|
||||
if stream.len() > MAX_STREAM {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() as u64 > MAX_STREAM {
|
||||
return None;
|
||||
}
|
||||
parse(&bytes)
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Option<CoreProps> {
|
||||
if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? != 1 {
|
||||
return None;
|
||||
}
|
||||
if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 {
|
||||
return None;
|
||||
}
|
||||
let offset = u32_at(bytes, 44)? as usize;
|
||||
if offset < 48 || !offset.is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let size = u32_at(bytes, offset)? as usize;
|
||||
let set = bytes.get(offset..offset.checked_add(size)?)?;
|
||||
let count = u32_at(set, 4)? as usize;
|
||||
if count > 4096 {
|
||||
return None;
|
||||
}
|
||||
let table_end = 8 + count * 8;
|
||||
let table = set.get(8..table_end)?;
|
||||
// Offsets are relative to the property set. Bound each value by the next property so a
|
||||
// forged string length cannot read another property's bytes as part of the title.
|
||||
let property = |id: u32| -> Option<&[u8]> {
|
||||
let entry = table.as_chunks::<8>().0.iter().find(|entry| u32_at(entry.as_slice(), 0) == Some(id))?.as_slice();
|
||||
let start = u32_at(entry, 4)? as usize;
|
||||
if start < table_end || !start.is_multiple_of(4) {
|
||||
return None;
|
||||
}
|
||||
let end = table
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|e| u32_at(e, 4))
|
||||
.map(|n| n as usize)
|
||||
.filter(|n| *n > start)
|
||||
.min()
|
||||
.unwrap_or(set.len())
|
||||
.min(set.len());
|
||||
set.get(start..end)
|
||||
};
|
||||
let cp = property(1)?;
|
||||
if u32_at(cp, 0)? != 2 {
|
||||
return None;
|
||||
} // VT_I2
|
||||
let codepage = u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?);
|
||||
let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default();
|
||||
Some(CoreProps {
|
||||
title: get(2),
|
||||
subject: get(3),
|
||||
creator: get(4),
|
||||
keywords: get(5),
|
||||
description: get(6),
|
||||
last_modified_by: get(8),
|
||||
revision: get(9).parse().unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn text(value: &[u8], codepage: u16) -> Option<String> {
|
||||
let kind = u32_at(value, 0)?;
|
||||
let count = u32_at(value, 4)? as usize;
|
||||
let size = match kind {
|
||||
30 => count, // CodePageString length is in bytes, even for code page 1200.
|
||||
31 => count.checked_mul(2)?, // UnicodeString length is in UTF-16 units.
|
||||
_ => return None,
|
||||
};
|
||||
if size > MAX_STRING {
|
||||
return None;
|
||||
}
|
||||
let bytes = value.get(8..8usize.checked_add(size)?)?;
|
||||
if kind == 31 || codepage == 1200 {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let units = bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).take_while(|u| *u != 0);
|
||||
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
|
||||
} else {
|
||||
let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default();
|
||||
if codepage == 65001 {
|
||||
Some(String::from_utf8_lossy(bytes).into_owned())
|
||||
} else if wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
|
||||
Some(bytes.iter().map(|b| wordcraft_doc::encoding::windows_byte(codepage, *b)).collect())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ const MAX: usize = 10_000;
|
||||
/// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices
|
||||
/// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the
|
||||
/// note subdocument.
|
||||
///
|
||||
/// The story CPs of a well-formed PLC ascend, so the stories are disjoint. A hostile table
|
||||
/// that steps back would make many notes read the same text again (each one materialised
|
||||
/// as its own part), so a story starting before the end of the last one kept is dropped:
|
||||
/// every subdocument CP becomes at most one note.
|
||||
pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> {
|
||||
let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default();
|
||||
let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default();
|
||||
@@ -14,12 +19,15 @@ pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize,
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut kept_to = 0u32;
|
||||
// The reference PLC's aFtnIdx order matches the story order; pair them by index.
|
||||
for (i, &cp) in refs.iter().enumerate() {
|
||||
if let (Some(a), Some(b)) = (txts.get(i), txts.get(i + 1))
|
||||
if let (Some(&a), Some(&b)) = (txts.get(i), txts.get(i + 1))
|
||||
&& b > a
|
||||
&& a >= kept_to
|
||||
{
|
||||
out.push((cp, (*a, *b)));
|
||||
out.push((cp, (a, b)));
|
||||
kept_to = b;
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! and record counts are capped, and decompressed metafiles are size-limited.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use image::ImageEncoder;
|
||||
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -138,7 +141,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
|
||||
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
|
||||
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
|
||||
_ => dib_to_png(data),
|
||||
_ => dib_to_png(data, limit),
|
||||
}
|
||||
}
|
||||
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
|
||||
@@ -175,7 +178,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
|
||||
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
|
||||
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8]) -> Option<Blip> {
|
||||
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
let header = u32_at(dib, 0)? as usize;
|
||||
if !(12..=124).contains(&header) {
|
||||
return None;
|
||||
@@ -193,7 +196,7 @@ fn dib_to_png(dib: &[u8]) -> Option<Blip> {
|
||||
// BI_BITFIELDS masks follow a 40-byte header.
|
||||
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
|
||||
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
|
||||
let file_size = 14usize.checked_add(dib.len())?;
|
||||
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
|
||||
let mut bmp = Vec::with_capacity(file_size);
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
|
||||
@@ -205,17 +208,43 @@ fn dib_to_png(dib: &[u8]) -> Option<Blip> {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_DIB_SIDE);
|
||||
limits.max_image_height = Some(MAX_DIB_SIDE);
|
||||
limits.max_alloc = Some(256 << 20);
|
||||
limits.max_alloc = Some((256 << 20).min(limit as u64));
|
||||
reader.limits(limits);
|
||||
let mut png = Vec::new();
|
||||
let mut png = PictureWriter { bytes: Vec::new(), limit };
|
||||
match reader.decode() {
|
||||
Ok(img) if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() => {
|
||||
Some(Blip { bytes: png, ext: "png", placeholder: false })
|
||||
Ok(img)
|
||||
if image::codecs::png::PngEncoder::new(&mut png).write_image(img.as_bytes(), img.width(), img.height(), img.color().into()).is_ok() =>
|
||||
{
|
||||
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
|
||||
}
|
||||
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap encoded bytes while the PNG encoder writes, before the buffer can grow.
|
||||
struct PictureWriter {
|
||||
bytes: Vec<u8>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl Write for PictureWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let Some(end) = self.bytes.len().checked_add(buf.len()).filter(|&end| end <= self.limit) else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "picture exceeds remaining media budget"));
|
||||
};
|
||||
if end > self.bytes.capacity() {
|
||||
let new_capacity = self.bytes.capacity().saturating_mul(2).max(end).min(self.limit);
|
||||
self.bytes.try_reserve_exact(new_capacity - self.bytes.len()).map_err(std::io::Error::other)?;
|
||||
}
|
||||
self.bytes.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Shapes.
|
||||
|
||||
|
||||
@@ -171,6 +171,11 @@ fn apply_section(s: &mut SectionProps, prl: &Prl) {
|
||||
/// `[cps[i], cps[i+1])`. Stories 0-5 are footnote/endnote separators; every section then owns
|
||||
/// six stories in the order even header, odd (default) header, even footer, odd (default)
|
||||
/// footer, first header, first footer. The CPs are relative to the header subdocument.
|
||||
///
|
||||
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
|
||||
/// the end of an earlier one (a hostile table pointing many sections at the same text, each
|
||||
/// read into its own part) is left empty, like a slot linked to the previous section's:
|
||||
/// every subdocument CP becomes at most one story.
|
||||
pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
|
||||
let Some((fc, lcb)) = fib.pair(pair::PLCF_HDD) else { return Vec::new() };
|
||||
let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
|
||||
@@ -178,9 +183,12 @@ pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
|
||||
let n = (lcb as usize / 4).min(6 + 6 * MAX_SECTIONS + 1);
|
||||
let cp_at = |i: usize| -> Option<u32> { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
|
||||
let mut stories = Vec::with_capacity(n);
|
||||
let mut kept_to = 0u32;
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
if let (Some(a), Some(b)) = (cp_at(i), cp_at(i + 1)) {
|
||||
stories.push((a, b.max(a)));
|
||||
let b = if a < kept_to { a } else { b.max(a) };
|
||||
kept_to = kept_to.max(b);
|
||||
stories.push((a, b));
|
||||
}
|
||||
}
|
||||
stories
|
||||
|
||||
@@ -263,7 +263,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
c.props.shading = *s;
|
||||
}
|
||||
if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) {
|
||||
c.props.valign = *v;
|
||||
c.props.valign = Some(*v);
|
||||
}
|
||||
}
|
||||
for &(a, b) in info.merges.iter().take(MAX_COLS) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::read;
|
||||
|
||||
mod drawing;
|
||||
mod encrypted;
|
||||
mod metadata;
|
||||
|
||||
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
|
||||
const TEXT_FC: u32 = 0x600;
|
||||
@@ -1501,6 +1502,92 @@ fn huge_story_ranges_are_clamped_to_their_subdocument() {
|
||||
assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote));
|
||||
}
|
||||
|
||||
/// The text of each note a paragraph references, in order.
|
||||
fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec<String> {
|
||||
p.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id),
|
||||
_ => None,
|
||||
})
|
||||
.map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_stories_pointing_at_the_same_text_are_read_once() {
|
||||
// 2000 footnote references whose PlcffndTxt steps back to CP 0 after every story
|
||||
// ([0, 10, 0, 10, …]): every other one was read into its own part, a copy of the whole
|
||||
// footnote subdocument each (1001 parts here; a large story times thousands of
|
||||
// references in a hostile file).
|
||||
let n = 2000u32;
|
||||
let text: &'static str = Box::leak(format!("A{}\r", "\u{2}".repeat(n as usize)).into_boxed_str());
|
||||
let ccp = text.chars().count() as u32;
|
||||
let refs: Vec<u32> = (1..=n).chain([ccp]).collect();
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
note_stories: vec!["Note body\r"],
|
||||
extra_pairs: vec![(2, plc(&refs, &1u16.to_le_bytes().repeat(n as usize))), (3, plc(&[0u32, 10].repeat(n as usize / 2 + 1), &[]))],
|
||||
..structured_spec(text, ccp)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Footnote).count(), 1);
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_stories_stepping_back_are_dropped_and_ordered_ones_kept() {
|
||||
// Story CPs [0, 5, 10, 3, 10] for four footnotes and four endnotes: the first two
|
||||
// stories are in order and read (the control); the third runs backwards and the fourth
|
||||
// starts inside the first, so the third and fourth references get no note.
|
||||
let text = "a\u{2}b\u{2}c\u{2}d\u{2}e\u{2}f\u{2}g\u{2}h\u{2}\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let stories = plc(&[0, 5, 10, 3, 10], &[]);
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
note_stories: vec!["Aaaa\rBbbb\r"],
|
||||
endnote_stories: vec!["Cccc\rDddd\r"],
|
||||
extra_pairs: vec![
|
||||
(2, plc(&[1, 3, 5, 7, ccp], &1u16.to_le_bytes().repeat(4))),
|
||||
(3, stories.clone()),
|
||||
(46, plc(&[9, 11, 13, 15, ccp], &1u16.to_le_bytes().repeat(4))),
|
||||
(47, stories),
|
||||
],
|
||||
..structured_spec(text, ccp)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
assert_eq!(note_texts(&doc, p0), ["Aaaa", "Bbbb", "Cccc", "Dddd"].map(String::from));
|
||||
assert_eq!(doc.parts.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_stories_pointing_at_the_same_text_are_read_once() {
|
||||
// Two sections whose odd headers both claim the header subdocument's [0, 5) (Plcfhdd
|
||||
// steps back to CP 0 inside the second section's slots): the second used to become a
|
||||
// second copy of the text. Its slot is now empty, which links it to the first section's.
|
||||
let text = "a\rb\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
// Six empty separator stories, then each section's six slots; the odd header is the
|
||||
// second slot.
|
||||
let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 0, 5, 5, 5, 5, 5], &[]);
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec!["Head\r"],
|
||||
sections: vec![(2, Vec::new()), (ccp, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())],
|
||||
extra_pairs: vec![(11, hdd)],
|
||||
..structured_spec(text, ccp)
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1);
|
||||
let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section");
|
||||
let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header");
|
||||
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
|
||||
assert_eq!(doc.last_section.headers.default, None);
|
||||
}
|
||||
|
||||
/// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes
|
||||
/// at TEXT_FC and the FIB claiming `ccp_text` main-document characters.
|
||||
fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec<u8> {
|
||||
|
||||
@@ -182,6 +182,36 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
// in between): each was read into its own part, so a hostile table could copy one story
|
||||
// thousands of times. The second story is dropped and its box stays empty.
|
||||
let tb = |spid: u32| sp(&[fsp(202, spid, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]);
|
||||
let art = art_content(&[], &[tb(1027), tb(1028)]);
|
||||
let flags = spa_flags(0, 0, 1, false);
|
||||
let spas = plc(&[1, 2, 4], &[spa(1027, [0, 0, 2880, 720], flags), spa(1028, [0, 0, 2880, 720], flags)].concat());
|
||||
// FTXBXS: cTxbx, cReusable, fReusable, reserved, lid, txidUndo.
|
||||
let ftxbxs = |lid: u32| {
|
||||
[1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &lid.to_le_bytes(), &0i32.to_le_bytes()]
|
||||
.concat()
|
||||
};
|
||||
let txbx = plc(&[0, 7, 0, 7], &[ftxbxs(1027), ftxbxs(1), ftxbxs(1028)].concat());
|
||||
let text = "A\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
// As above: the endnote slot holds the text box subdocument, then the FIB is patched.
|
||||
let spec =
|
||||
StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)])).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let stories: Vec<Option<u32>> =
|
||||
p0.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect();
|
||||
let [Some(first), None] = stories[..] else { panic!("stories: {stories:?}") };
|
||||
let part = doc.parts.get(&first).expect("text box story part");
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
assert_eq!(doc.parts.len(), 1);
|
||||
}
|
||||
|
||||
/// Overwrite little-endian u32s in the WordDocument stream of a compound file.
|
||||
fn patch_word(file: &[u8], at: &[(usize, u32)]) -> Vec<u8> {
|
||||
use std::io::Read;
|
||||
@@ -274,3 +304,30 @@ proptest! {
|
||||
let _ = read(&struct_doc(&spec));
|
||||
}
|
||||
}
|
||||
|
||||
// A 1×1 24-bit DIB, including its padded four-byte row.
|
||||
fn one_pixel_dib() -> Vec<u8> {
|
||||
let mut dib = [40u32, 1, 1].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
|
||||
dib.extend_from_slice(&1u16.to_le_bytes());
|
||||
dib.extend_from_slice(&24u16.to_le_bytes());
|
||||
dib.extend_from_slice(&[0u8; 24]);
|
||||
dib.extend_from_slice(&[30, 60, 90, 0]);
|
||||
dib
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dib_conversion_caps_header_and_encoded_bytes() {
|
||||
let dib = one_pixel_dib();
|
||||
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (record, _) = officeart::record(&record).unwrap();
|
||||
let bmp_size = 14 + dib.len();
|
||||
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
|
||||
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
|
||||
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
|
||||
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
|
||||
assert_eq!((normal.ext, normal.placeholder), ("png", false));
|
||||
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
|
||||
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! OLE property sets constructed from MS-OLEPS, exercised through the public DOC reader.
|
||||
use super::{Piece, body_texts, text_doc};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
fn summary(cp: u16, values: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut props = vec![(1, [2u32.to_le_bytes().as_slice(), &cp.to_le_bytes(), &[0, 0]].concat())];
|
||||
for (id, kind, bytes) in values {
|
||||
let size = if *kind == 31 { bytes.len() / 2 } else { bytes.len() } as u32;
|
||||
let mut value = [kind.to_le_bytes().as_slice(), &size.to_le_bytes(), bytes].concat();
|
||||
while !value.len().is_multiple_of(4) {
|
||||
value.push(0);
|
||||
}
|
||||
props.push((*id, value));
|
||||
}
|
||||
let mut section = vec![0; 8 + 8 * props.len()];
|
||||
section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes());
|
||||
for (i, (id, value)) in props.iter().enumerate() {
|
||||
section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes());
|
||||
let offset = section.len() as u32;
|
||||
section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes());
|
||||
section.extend_from_slice(value);
|
||||
}
|
||||
let size = section.len() as u32;
|
||||
section[..4].copy_from_slice(&size.to_le_bytes());
|
||||
let mut header = vec![0; 48];
|
||||
header[..2].copy_from_slice(&[0xfe, 0xff]);
|
||||
header[24..28].copy_from_slice(&1u32.to_le_bytes());
|
||||
header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]);
|
||||
header[44..48].copy_from_slice(&48u32.to_le_bytes());
|
||||
header.extend(section);
|
||||
header
|
||||
}
|
||||
|
||||
fn open_with_metadata(bytes: &[u8], template: bool) -> wordcraft_doc::Document {
|
||||
let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0);
|
||||
let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
|
||||
cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap();
|
||||
if template {
|
||||
let mut stream = cfb.open_stream("/WordDocument").unwrap();
|
||||
stream.seek(SeekFrom::Start(0x0a)).unwrap();
|
||||
stream.write_all(&0x1001u16.to_le_bytes()).unwrap();
|
||||
}
|
||||
let doc = crate::read(&cfb.into_inner().into_inner()).unwrap();
|
||||
assert_eq!(body_texts(&doc), ["Body stays readable"]);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_and_dot_import_summary_text_properties() {
|
||||
let data = summary(
|
||||
1252,
|
||||
&[
|
||||
(2, 30, b"R\xe9sum\xe9\0".to_vec()),
|
||||
(3, 30, b"Subject\0".to_vec()),
|
||||
(4, 30, b"Writer\0".to_vec()),
|
||||
(5, 30, b"one;two\0".to_vec()),
|
||||
(6, 30, b"Description\0".to_vec()),
|
||||
(8, 30, b"Editor\0".to_vec()),
|
||||
(9, 30, b"12\0".to_vec()),
|
||||
],
|
||||
);
|
||||
for template in [false, true] {
|
||||
let c = open_with_metadata(&data, template).core;
|
||||
assert_eq!(c.title, "Résumé");
|
||||
assert_eq!(c.subject, "Subject");
|
||||
assert_eq!(c.creator, "Writer");
|
||||
assert_eq!(c.keywords, "one;two");
|
||||
assert_eq!(c.description, "Description");
|
||||
assert_eq!(c.last_modified_by, "Editor");
|
||||
assert_eq!(c.revision, 12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_handles_codepages_and_utf16_length_units() {
|
||||
let wide: Vec<u8> = "Unicode ä 文 😀\0".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
for kind in [30, 31] {
|
||||
assert_eq!(open_with_metadata(&summary(1200, &[(2, kind, wide.clone())]), false).core.title, "Unicode ä 文 😀");
|
||||
}
|
||||
assert_eq!(open_with_metadata(&summary(65001, &[(2, 30, "UTF-8 ä\0".as_bytes().to_vec())]), false).core.title, "UTF-8 ä");
|
||||
assert_eq!(open_with_metadata(&summary(1251, &[(2, 30, vec![0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0])]), false).core.title, "Привет");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_oversized_summary_does_not_break_doc_open() {
|
||||
let valid = summary(1252, &[(2, 30, b"Title\0".to_vec())]);
|
||||
for n in [0, 2, 24, 44, 47, 55, valid.len() - 5] {
|
||||
assert!(open_with_metadata(&valid[..n], false).core.title.is_empty());
|
||||
}
|
||||
for (at, value) in [(44, u32::MAX), (48, u32::MAX), (52, u32::MAX), (68, 4), (84, u32::MAX)] {
|
||||
let mut bad = valid.clone();
|
||||
bad[at..at + 4].copy_from_slice(&value.to_le_bytes());
|
||||
assert!(open_with_metadata(&bad, false).core.title.is_empty(), "offset {at}");
|
||||
}
|
||||
let huge = summary(1252, &[(2, 30, vec![b'x'; (64 << 10) + 1])]);
|
||||
assert!(open_with_metadata(&huge, false).core.title.is_empty());
|
||||
assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty());
|
||||
}
|
||||
+101
-18
@@ -5,7 +5,9 @@
|
||||
//!
|
||||
//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader;
|
||||
//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader:
|
||||
//! part count, part size, total size and base64 length are capped.
|
||||
//! every part counts against the part count, part size and total size limits whether it's kept
|
||||
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
|
||||
//! capped as the XML tree reader caps a part's.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -92,8 +94,24 @@ struct Part {
|
||||
data: Option<Data>,
|
||||
}
|
||||
|
||||
/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept).
|
||||
const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4;
|
||||
/// Deepest element nesting read: a part's inline XML starts below the package, part and data
|
||||
/// elements and may nest as deep as the XML tree reader takes ([`xml::MAX_DEPTH`] open elements
|
||||
/// and an empty one inside the deepest).
|
||||
const MAX_NESTING: usize = 3 + xml::MAX_DEPTH + 1;
|
||||
|
||||
/// Most elements in one child of the package: a part, its data element and as many as the XML
|
||||
/// tree reader takes in a part ([`xml::MAX_ELEMENTS`]).
|
||||
const MAX_PART_ELEMENTS: usize = 2 + xml::MAX_ELEMENTS;
|
||||
|
||||
/// Longest base64 text of a part that decodes to at most `bytes` (whitespace isn't kept).
|
||||
fn max_base64(bytes: u64) -> u64 {
|
||||
(bytes / 3).saturating_add(1).saturating_mul(4)
|
||||
}
|
||||
|
||||
/// Bytes the next part may have: its own size limit, or what's left of the package's.
|
||||
fn room(total: u64) -> u64 {
|
||||
MAX_PART.min(MAX_TOTAL.saturating_sub(total))
|
||||
}
|
||||
|
||||
/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts'
|
||||
/// content types (unless the file carries one).
|
||||
@@ -103,6 +121,9 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
let mut r = NsReader::from_reader(src);
|
||||
r.config_mut().check_end_names = true;
|
||||
let mut depth = 0usize;
|
||||
// Children of the package (parts or anything else), and elements in the current one.
|
||||
let mut entries = 0usize;
|
||||
let mut elements = 0usize;
|
||||
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
let mut types: Vec<(String, String)> = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
@@ -118,15 +139,26 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
let empty = matches!(ev, Event::Empty(_));
|
||||
depth += 1;
|
||||
if depth > MAX_NESTING {
|
||||
return Err(DocxError::Limit("XML nesting too deep".into()));
|
||||
}
|
||||
if depth == 2 {
|
||||
entries += 1;
|
||||
elements = 0;
|
||||
if entries > MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
|
||||
}
|
||||
}
|
||||
elements += 1;
|
||||
if elements > MAX_PART_ELEMENTS {
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
}
|
||||
let local = e.local_name().as_ref().to_vec();
|
||||
match depth {
|
||||
1 if !(in_pkg && local == b"package") => {
|
||||
return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into()));
|
||||
}
|
||||
2 if in_pkg && local == b"part" => {
|
||||
if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
|
||||
}
|
||||
let name = attr(e, b"name").unwrap_or_default();
|
||||
let content_type = attr(e, b"contentType").unwrap_or_default();
|
||||
part = Some(Part { name, content_type, data: None });
|
||||
@@ -167,7 +199,12 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
&& let Some(Data::Xml(start)) = p.data
|
||||
{
|
||||
p.data = None;
|
||||
let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope);
|
||||
let frag = src.get(start..before).unwrap_or_default();
|
||||
// Refused before it's copied when it can't fit.
|
||||
if frag.len() as u64 > room(total) {
|
||||
return Err(DocxError::Limit(format!("part {} is larger than the size limit", p.name)));
|
||||
}
|
||||
let xml = with_scope(frag, &scope);
|
||||
store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?;
|
||||
}
|
||||
if depth == 2
|
||||
@@ -183,15 +220,18 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Event::Text(ref t) if depth == 3 => {
|
||||
if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() {
|
||||
let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
|
||||
if b64.len() as u64 > MAX_BASE64 {
|
||||
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
|
||||
Event::Text(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
|
||||
Event::CData(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
|
||||
Event::GeneralRef(ref g) if depth == 3 => match g.resolve_char_ref() {
|
||||
// A character reference stands for its character (`A` is an `A`).
|
||||
Ok(Some(c)) => add_base64(&mut part, c.encode_utf8(&mut [0; 4]).as_bytes(), total)?,
|
||||
// An entity isn't base64: binary data holding one is broken, not shorter.
|
||||
_ => {
|
||||
if let Some(Part { name, data: Some(Data::Binary(_)), .. }) = &part {
|
||||
return Err(DocxError::Xml(format!("part {name} has invalid base64 data")));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
@@ -225,7 +265,21 @@ fn finish_part(p: Part, files: &mut BTreeMap<String, Vec<u8>>, types: &mut Vec<(
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a part, within the size limits; a repeated or empty name is ignored.
|
||||
/// Text (or CDATA) inside a part's `pkg:binaryData`: its base64 without whitespace, checked
|
||||
/// against the size limits before it's copied.
|
||||
fn add_base64(part: &mut Option<Part>, text: &[u8], total: u64) -> Result<(), DocxError> {
|
||||
let Some(Part { data: Some(Data::Binary(b64)), .. }) = part else { return Ok(()) };
|
||||
let s = std::str::from_utf8(text).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let more = s.bytes().filter(|b| !b.is_ascii_whitespace()).count();
|
||||
if b64.len().saturating_add(more) as u64 > max_base64(room(total)) {
|
||||
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
|
||||
}
|
||||
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a part, within the size limits; a repeated or empty name isn't stored but counts against
|
||||
/// them all the same (as a repeated zip entry does).
|
||||
fn store(
|
||||
name: &str,
|
||||
content_type: &str,
|
||||
@@ -235,9 +289,6 @@ fn store(
|
||||
total: &mut u64,
|
||||
) -> Result<(), DocxError> {
|
||||
let name = name.trim_start_matches('/');
|
||||
if name.is_empty() || files.contains_key(name) {
|
||||
return Ok(());
|
||||
}
|
||||
let len = bytes.len() as u64;
|
||||
if len > MAX_PART {
|
||||
return Err(DocxError::Limit(format!("part {name} is larger than the size limit")));
|
||||
@@ -246,6 +297,9 @@ fn store(
|
||||
if *total > MAX_TOTAL {
|
||||
return Err(DocxError::Limit("the package is larger than the size limit".into()));
|
||||
}
|
||||
if name.is_empty() || files.contains_key(name) {
|
||||
return Ok(());
|
||||
}
|
||||
if !content_type.is_empty() && !content_type.contains(['\r', '\n']) {
|
||||
types.push((name.to_string(), content_type.to_string()));
|
||||
}
|
||||
@@ -408,4 +462,33 @@ mod tests {
|
||||
assert_eq!(with_scope(own, &scope), own.to_vec());
|
||||
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
|
||||
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
|
||||
let package = |levels: usize| {
|
||||
format!(
|
||||
"<pkg:package xmlns:pkg=\"{PKG_NS}\"><pkg:part pkg:name=\"/a.xml\"><pkg:xmlData>{}</pkg:xmlData></pkg:part></pkg:package>",
|
||||
inline(levels)
|
||||
)
|
||||
};
|
||||
assert!(xml::parse(inline(xml::MAX_DEPTH).as_bytes()).is_ok());
|
||||
assert!(unpack(package(xml::MAX_DEPTH).as_bytes()).is_ok());
|
||||
assert!(matches!(xml::parse(inline(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
|
||||
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_nameless_parts_count_against_the_size_limits() {
|
||||
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
|
||||
for name in ["/a.bin", "/a.bin", ""] {
|
||||
store(name, "", vec![0; 10], &mut files, &mut types, &mut total).unwrap();
|
||||
}
|
||||
assert_eq!((files.len(), total), (1, 30));
|
||||
// Near the package limit, what's left of it is all the next part may have.
|
||||
total = MAX_TOTAL - 5;
|
||||
assert_eq!(room(total), 5);
|
||||
assert_eq!(max_base64(5), 8);
|
||||
assert!(matches!(store("/b.bin", "", vec![0; 6], &mut files, &mut types, &mut total), Err(DocxError::Limit(_))));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them.
|
||||
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`; legacy VML paths) and ink strokes
|
||||
//! drawn with them.
|
||||
|
||||
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS};
|
||||
|
||||
@@ -82,6 +83,229 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
|
||||
(!f.paths.is_empty()).then_some(f)
|
||||
}
|
||||
|
||||
/// Largest VML path coordinate kept (coordinate-space units).
|
||||
const MAX_VML: f64 = 1.0e12;
|
||||
/// Characters of a VML `path` attribute read; the rest is ignored.
|
||||
const MAX_VML_PATH: usize = 1 << 20;
|
||||
/// Parameters of a VML path kept, all commands together.
|
||||
const MAX_VML_PARAMS: usize = MAX_POINTS * 8;
|
||||
|
||||
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4 §14.1.2.19).
|
||||
pub struct VmlPath {
|
||||
pub freeform: Freeform,
|
||||
/// `nf` in the path: no fill.
|
||||
pub no_fill: bool,
|
||||
/// `ns` in the path: no outline.
|
||||
pub no_stroke: bool,
|
||||
}
|
||||
|
||||
/// The VML path commands that are two letters long.
|
||||
const VML_TWO: [&str; 11] = ["nf", "ns", "ae", "al", "at", "ar", "wa", "wr", "qx", "qy", "qb"];
|
||||
|
||||
/// The VML `path` of a shape, mapped onto its `w` × `h` frame (points) through its `coordsize`
|
||||
/// (default `1000,1000`) and `coordorigin` (default `0,0`). Commands: `m`/`t` move (absolute /
|
||||
/// relative), `l`/`r` line, `c`/`v` cubic Bézier (flattened), `x` close, `e` end, `nf`/`ns` no
|
||||
/// fill / no outline; arcs (`at`, `ar`, `wa`, `wr`) and quadrants (`qx`, `qy`) are drawn straight
|
||||
/// to their end, `qb` straight to its last point, and ellipses (`ae`, `al`) are skipped. An empty
|
||||
/// parameter is 0. `None` when the path has no points, or uses formulas (`@n`, from `v:formulas`,
|
||||
/// which aren't evaluated): such a shape isn't drawn rather than drawn wrong.
|
||||
pub fn vml_path(path: &str, coordsize: Option<&str>, coordorigin: Option<&str>, w: f32, h: f32) -> Option<VmlPath> {
|
||||
if path.contains('@') {
|
||||
return None;
|
||||
}
|
||||
let pair = |v: Option<&str>| -> Option<(f64, f64)> {
|
||||
let v = v?;
|
||||
let (a, b) = v.split_once(',').unwrap_or((v, ""));
|
||||
let n = |s: &str| int(s).map(|i| (i as f64).clamp(-MAX_VML, MAX_VML));
|
||||
Some((n(a)?, n(b).unwrap_or(0.0)))
|
||||
};
|
||||
let size = |v: f64| if v > 0.0 { v } else { 1000.0 };
|
||||
let (cw, ch) = pair(coordsize).map_or((1000.0, 1000.0), |(a, b)| (size(a), size(b)));
|
||||
let (ox, oy) = pair(coordorigin).unwrap_or((0.0, 0.0));
|
||||
let (sx, sy) = (w as f64 / cw, h as f64 / ch);
|
||||
let scale = |[x, y]: [f64; 2]| -> [f32; 2] { [((x - ox) * sx) as f32, ((y - oy) * sy) as f32] };
|
||||
|
||||
let mut d = Draw::default();
|
||||
let (mut no_fill, mut no_stroke) = (false, false);
|
||||
for (cmd, params) in vml_commands(path) {
|
||||
if d.full() {
|
||||
break;
|
||||
}
|
||||
let pts: Vec<[f64; 2]> = params.as_chunks::<2>().0.to_vec();
|
||||
match cmd.as_str() {
|
||||
"m" | "t" => {
|
||||
for (i, &p) in pts.iter().enumerate() {
|
||||
let p = if cmd == "t" { d.rel(p) } else { p };
|
||||
if i == 0 {
|
||||
d.move_to(p);
|
||||
} else {
|
||||
d.line_to(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
"l" | "r" | "qx" | "qy" => {
|
||||
for &p in &pts {
|
||||
let p = if cmd == "r" { d.rel(p) } else { p };
|
||||
d.line_to(p);
|
||||
}
|
||||
}
|
||||
"c" | "v" => {
|
||||
for c in pts.as_chunks::<3>().0 {
|
||||
let start = d.at;
|
||||
let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(c.iter().map(|&p| if cmd == "v" { d.rel(p) } else { p })).collect();
|
||||
for i in 1..=CURVE_STEPS {
|
||||
d.line_to(bezier(&ctrl, i as f64 / CURVE_STEPS as f64));
|
||||
}
|
||||
}
|
||||
}
|
||||
"qb" => {
|
||||
if let Some(&p) = pts.last() {
|
||||
d.line_to(p);
|
||||
}
|
||||
}
|
||||
// Bounding box, then start and end: `at`/`wa` draw a line to the start, `ar`/`wr` move there.
|
||||
"at" | "ar" | "wa" | "wr" => {
|
||||
for [_, _, s, e] in pts.as_chunks::<4>().0 {
|
||||
if matches!(cmd.as_str(), "ar" | "wr") {
|
||||
d.move_to(*s);
|
||||
} else {
|
||||
d.line_to(*s);
|
||||
}
|
||||
d.line_to(*e);
|
||||
}
|
||||
}
|
||||
"x" => d.end(true),
|
||||
"e" => d.end(false),
|
||||
"nf" => no_fill = true,
|
||||
"ns" => no_stroke = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
d.end(false);
|
||||
let paths = d.done.into_iter().map(|(pts, closed)| FreePath { pts: pts.into_iter().map(scale).collect(), closed }).collect();
|
||||
let freeform = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized();
|
||||
(!freeform.paths.is_empty()).then_some(VmlPath { freeform, no_fill, no_stroke })
|
||||
}
|
||||
|
||||
/// The pen while a VML path is drawn: where it is, where the subpath began, the subpath being
|
||||
/// drawn and those done (closed or not).
|
||||
#[derive(Default)]
|
||||
struct Draw {
|
||||
at: [f64; 2],
|
||||
start: [f64; 2],
|
||||
cur: Vec<[f64; 2]>,
|
||||
done: Vec<(Vec<[f64; 2]>, bool)>,
|
||||
}
|
||||
|
||||
impl Draw {
|
||||
fn full(&self) -> bool {
|
||||
self.done.len() >= MAX_PATHS
|
||||
}
|
||||
|
||||
fn rel(&self, [x, y]: [f64; 2]) -> [f64; 2] {
|
||||
[(self.at[0] + x).clamp(-MAX_VML, MAX_VML), (self.at[1] + y).clamp(-MAX_VML, MAX_VML)]
|
||||
}
|
||||
|
||||
fn move_to(&mut self, p: [f64; 2]) {
|
||||
self.end(false);
|
||||
self.at = p;
|
||||
self.start = p;
|
||||
self.cur.push(p);
|
||||
}
|
||||
|
||||
fn line_to(&mut self, p: [f64; 2]) {
|
||||
if self.cur.is_empty() {
|
||||
self.cur.push(self.at);
|
||||
self.start = self.at;
|
||||
}
|
||||
if self.cur.len() < MAX_POINTS {
|
||||
self.cur.push(p);
|
||||
}
|
||||
self.at = p;
|
||||
}
|
||||
|
||||
/// Finish the subpath being drawn, closed back to its start or not.
|
||||
fn end(&mut self, closed: bool) {
|
||||
if !self.cur.is_empty() && !self.full() {
|
||||
self.done.push((std::mem::take(&mut self.cur), closed));
|
||||
}
|
||||
self.cur.clear();
|
||||
if closed {
|
||||
self.at = self.start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands of a VML path with their parameters: a command is a letter (or one of
|
||||
/// [`VML_TWO`]) followed by numbers separated by commas or spaces. An empty parameter (nothing
|
||||
/// before a comma) is 0, and so is a formula reference (`@n`, not expected here: see
|
||||
/// [`vml_path`]); fractions are dropped. Anything else is skipped.
|
||||
fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
|
||||
let mut out: Vec<(String, Vec<f64>)> = Vec::new();
|
||||
let mut chars = path.chars().take(MAX_VML_PATH).peekable();
|
||||
// Whether a number was read since the command or the last comma.
|
||||
let mut has_num = true;
|
||||
let mut kept = 0usize;
|
||||
let mut push = |out: &mut Vec<(String, Vec<f64>)>, v: f64| {
|
||||
if kept < MAX_VML_PARAMS
|
||||
&& let Some((_, p)) = out.last_mut()
|
||||
{
|
||||
p.push(v);
|
||||
kept += 1;
|
||||
}
|
||||
};
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_ascii_alphabetic() {
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
has_num = false;
|
||||
let lower = c.to_ascii_lowercase();
|
||||
let mut cmd = lower.to_string();
|
||||
if let Some(&n) = chars.peek() {
|
||||
let two: String = [lower, n.to_ascii_lowercase()].iter().collect();
|
||||
if VML_TWO.contains(&two.as_str()) {
|
||||
chars.next();
|
||||
cmd = two;
|
||||
}
|
||||
}
|
||||
out.push((cmd, Vec::new()));
|
||||
} else if c == ',' {
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
has_num = false;
|
||||
} else if matches!(c, '@' | '-' | '+' | '.') || c.is_ascii_digit() {
|
||||
let mut v = c.to_digit(10).map_or(0.0, f64::from);
|
||||
let mut frac = c == '.';
|
||||
while let Some(&n) = chars.peek() {
|
||||
if n == '.' {
|
||||
frac = true;
|
||||
} else if n.is_ascii_digit() {
|
||||
if !frac {
|
||||
v = (v * 10.0 + n.to_digit(10).map_or(0.0, f64::from)).min(MAX_VML);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
let v = match c {
|
||||
'@' => 0.0,
|
||||
'-' => -v,
|
||||
_ => v,
|
||||
};
|
||||
has_num = true;
|
||||
push(&mut out, v);
|
||||
}
|
||||
// Whitespace and anything else only separate numbers.
|
||||
}
|
||||
if !has_num {
|
||||
push(&mut out, 0.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`,
|
||||
/// `Ink Highlighter 4`).
|
||||
pub fn ink_tool(name: &str) -> Option<InkTool> {
|
||||
|
||||
+61
-49
@@ -20,8 +20,9 @@ use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts};
|
||||
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
|
||||
use wordcraft_doc::{
|
||||
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
|
||||
};
|
||||
@@ -33,6 +34,10 @@ use crate::xml::El;
|
||||
pub(crate) use props::PropCtx;
|
||||
use story::StoryCtx;
|
||||
|
||||
/// Most fonts read from a font table, and the longest name (hostile files).
|
||||
const MAX_TABLE_FONTS: usize = 2000;
|
||||
const MAX_FONT_NAME: usize = 256;
|
||||
|
||||
/// Most parts (headers, notes, comments, text boxes) we create from one file.
|
||||
const MAX_PARTS: usize = 50_000;
|
||||
/// Deepest chain of headers/footers whose own sections reference further headers/footers.
|
||||
@@ -171,6 +176,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
// The font table and the fonts embedded through it ride along as opaque parts.
|
||||
if let Some(p) = part(rt::FONT_TABLE) {
|
||||
r.keep_part_tree(&p);
|
||||
r.read_font_families(&p);
|
||||
}
|
||||
if let Some(p) = part(rt::THEME) {
|
||||
r.read_theme(&p);
|
||||
@@ -442,8 +448,15 @@ fn toc_field_to_heading(body: &mut Blocks) {
|
||||
}
|
||||
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
|
||||
let (field, end) = match p.object_at(0) {
|
||||
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => (
|
||||
InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false, form: String::new() },
|
||||
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
|
||||
InlineObject::Field {
|
||||
instr: instr.clone(),
|
||||
result: String::new(),
|
||||
locked: *locked,
|
||||
dirty: *dirty,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
Some(toc_range_end(body, i)?),
|
||||
),
|
||||
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
|
||||
@@ -581,6 +594,19 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`).
|
||||
fn read_font_families(&mut self, path: &str) {
|
||||
let Ok(Some(table)) = self.xml(path) else { return };
|
||||
self.doc.settings.roman_fonts = table
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn xml(&self, path: &str) -> Result<Option<El>, DocxError> {
|
||||
self.pkg.xml(path)
|
||||
}
|
||||
@@ -708,6 +734,9 @@ impl Reader<'_> {
|
||||
if let Some(f) = face("a:minorFont") {
|
||||
self.doc.settings.minor_font = f;
|
||||
}
|
||||
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
|
||||
self.pc.major_cs = cs("a:majorFont");
|
||||
self.pc.minor_cs = cs("a:minorFont");
|
||||
}
|
||||
self.pc.major_font = self.doc.settings.major_font.clone();
|
||||
self.pc.minor_font = self.doc.settings.minor_font.clone();
|
||||
@@ -873,56 +902,38 @@ impl Reader<'_> {
|
||||
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
|
||||
}
|
||||
for c in s.children("w:tblStylePr").take(64) {
|
||||
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e));
|
||||
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
|
||||
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
|
||||
match c.attr("w:type") {
|
||||
Some("wholeTable") => {
|
||||
if let Some(p) = c.child("w:pPr") {
|
||||
st.para.overlay(&self.pc.ppr(p).0);
|
||||
}
|
||||
st.chr.overlay(&chr);
|
||||
if fill.is_some() {
|
||||
t.fill = fill;
|
||||
}
|
||||
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
|
||||
t.borders = Some(props::borders(b));
|
||||
}
|
||||
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
|
||||
let tc = c.child("w:tcPr");
|
||||
let ppr = c.child("w:pPr").map(|p| self.pc.ppr(p).0);
|
||||
let f = PartFormat {
|
||||
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
|
||||
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
|
||||
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
|
||||
align: ppr.as_ref().and_then(|p| p.align),
|
||||
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
|
||||
"center" => VAlign::Center,
|
||||
"bottom" => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
}),
|
||||
};
|
||||
if part == TablePart::WholeTable {
|
||||
// The whole table's paragraph and run properties are the style's own; its
|
||||
// borders the table borders.
|
||||
if let Some(p) = ppr {
|
||||
st.para.overlay(&p);
|
||||
}
|
||||
Some("firstRow") => {
|
||||
t.header_fill = fill;
|
||||
t.header_chr = chr;
|
||||
t.header_borders = cell_borders;
|
||||
st.chr.overlay(&f.chr);
|
||||
if f.fill.is_some() {
|
||||
t.fill = f.fill;
|
||||
}
|
||||
Some("band1Horz") => {
|
||||
t.band_fill = fill;
|
||||
t.band_chr = chr;
|
||||
t.band_borders = cell_borders;
|
||||
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
|
||||
t.borders = Some(props::borders(b));
|
||||
}
|
||||
Some("firstCol") => {
|
||||
t.first_col_chr = chr;
|
||||
t.first_col_fill = fill;
|
||||
t.first_col_borders = cell_borders;
|
||||
if f.v_align.is_some() {
|
||||
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
|
||||
}
|
||||
Some("lastCol") => {
|
||||
t.last_col_chr = chr;
|
||||
t.last_col_fill = fill;
|
||||
t.last_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastRow") => {
|
||||
t.total_chr = chr;
|
||||
t.total_fill = fill;
|
||||
// The top rule is kept on its own (as built-in styles have it); the other edges
|
||||
// are the region's borders.
|
||||
t.total_border_top = cell_borders.and_then(|b| b.top);
|
||||
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
|
||||
}
|
||||
Some("band1Vert") => {
|
||||
t.col_band_fill = fill;
|
||||
t.col_band_chr = chr;
|
||||
t.col_band_borders = cell_borders;
|
||||
}
|
||||
_ => {}
|
||||
} else {
|
||||
t.set_part(part, f);
|
||||
}
|
||||
}
|
||||
t
|
||||
@@ -1069,6 +1080,7 @@ impl Reader<'_> {
|
||||
if let Some(m) = mode.and_then(u32_of) {
|
||||
s.compat_mode = m.clamp(11, 99);
|
||||
}
|
||||
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
|
||||
}
|
||||
"w:trackRevisions" => s.track_changes = on_off(k),
|
||||
"w:defaultTabStop" => {
|
||||
|
||||
@@ -9,7 +9,7 @@ use wordcraft_doc::props::{
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
SectionProps, SectionStart,
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::drawing_color::adjust_lum;
|
||||
@@ -43,6 +43,9 @@ use crate::xml::El;
|
||||
pub struct PropCtx {
|
||||
pub major_font: String,
|
||||
pub minor_font: String,
|
||||
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name.
|
||||
pub major_cs: String,
|
||||
pub minor_cs: String,
|
||||
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
|
||||
/// package has none, and then `w:themeColor` falls back to the fixed colour.
|
||||
pub theme_colors: Vec<Rgb>,
|
||||
@@ -82,7 +85,10 @@ impl PropCtx {
|
||||
}
|
||||
|
||||
fn theme_font(&self, t: &str) -> Option<String> {
|
||||
let f = if t.starts_with("major") { &self.major_font } else { &self.minor_font };
|
||||
let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) };
|
||||
// Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one
|
||||
// names nothing, so the font beside it or the inherited one applies.
|
||||
let f = if t.ends_with("Bidi") { cs } else { latin };
|
||||
(!f.is_empty()).then(|| f.clone())
|
||||
}
|
||||
|
||||
@@ -552,13 +558,16 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
|
||||
"w:noWrap" => c.no_wrap = on_off(k),
|
||||
"w:hideMark" => c.hide_mark = on_off(k),
|
||||
"w:tcMar" => c.margins = Some(margins(k)),
|
||||
"w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
|
||||
"w:textDirection" => {
|
||||
c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
|
||||
c.text_direction_set = k.attr("w:val") == Some("lrTb");
|
||||
}
|
||||
"w:vAlign" => {
|
||||
c.valign = match k.attr("w:val") {
|
||||
c.valign = Some(match k.attr("w:val") {
|
||||
Some("center") => VAlign::Center,
|
||||
Some("bottom") => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
}
|
||||
})
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -641,14 +650,19 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
}
|
||||
"w:footnotePr" => s.footnote_pr = note_pr(k, true),
|
||||
"w:endnotePr" => s.endnote_pr = note_pr(k, true),
|
||||
"w:formProt" => s.form_prot = Some(on_off(k)),
|
||||
"w:titlePg" => s.title_page = on_off(k),
|
||||
"w:bidi" => s.rtl = on_off(k),
|
||||
"w:textDirection" => s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
|
||||
"w:textDirection" => {
|
||||
s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
|
||||
s.text_direction_set = k.attr("w:val") == Some("lrTb");
|
||||
}
|
||||
"w:pgNumType" => {
|
||||
s.page_num_start = k.attr("w:start").and_then(u32_of);
|
||||
if let Some(f) = k.attr("w:fmt") {
|
||||
s.page_num_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
|
||||
}
|
||||
"w:lnNumType" => {
|
||||
let mut l = LineNumbering::default();
|
||||
@@ -668,9 +682,10 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
}
|
||||
"w:vAlign" => {
|
||||
s.valign = match k.attr("w:val") {
|
||||
Some("center") | Some("both") => VAlign::Center,
|
||||
Some("bottom") => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
Some("center") => PageVAlign::Center,
|
||||
Some("both") => PageVAlign::Justify,
|
||||
Some("bottom") => PageVAlign::Bottom,
|
||||
_ => PageVAlign::Top,
|
||||
}
|
||||
}
|
||||
"w:pgBorders" => s.page_borders = Some(borders(k)),
|
||||
|
||||
@@ -29,7 +29,9 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
|
||||
"ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => {
|
||||
ShapeKind::Ellipse
|
||||
}
|
||||
"triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
|
||||
"flowChartOr" => ShapeKind::FlowchartOr,
|
||||
"triangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
|
||||
"rtTriangle" => ShapeKind::RightTriangle,
|
||||
"diamond" | "flowChartDecision" => ShapeKind::Diamond,
|
||||
"line" | "lineInv" => ShapeKind::Line,
|
||||
"straightConnector1" => ShapeKind::StraightConnector,
|
||||
@@ -181,6 +183,11 @@ mod tests {
|
||||
assert_eq!(p.fill, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flowchart_or_keeps_its_spokes() {
|
||||
assert_eq!(preset_kind("flowChartOr"), ShapeKind::FlowchartOr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_paint_wins_over_the_style() {
|
||||
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:noFill/><a:ln><a:solidFill><a:srgbClr val="EE0000"/></a:solidFill></a:ln></wps:spPr>"#);
|
||||
@@ -234,7 +241,7 @@ mod tests {
|
||||
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
|
||||
// star, rounded callout, elbow connector, a bevel (its box).
|
||||
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
|
||||
assert_eq!(vml_preset_kind(6), Some(ShapeKind::Triangle));
|
||||
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
|
||||
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
|
||||
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
|
||||
assert_eq!(vml_preset_kind(34), Some(ShapeKind::ElbowConnector));
|
||||
|
||||
@@ -63,6 +63,8 @@ struct FieldState {
|
||||
buf: Vec<(Item, CharProps)>,
|
||||
props: CharProps,
|
||||
locked: bool,
|
||||
/// `w:dirty` from the begin marker: the result is stale, Word refreshes it on open.
|
||||
dirty: bool,
|
||||
/// Kept as a range field: its result flows into the document as ordinary content between
|
||||
/// `FieldStart` and `FieldEnd` markers instead of being buffered.
|
||||
range: bool,
|
||||
@@ -397,6 +399,11 @@ impl Reader<'_> {
|
||||
para.text = pb.text;
|
||||
para.runs = pb.runs;
|
||||
para.objects = pb.objects;
|
||||
// Paragraph boundaries inside an instruction are hidden with the field code.
|
||||
// The result paragraph still takes a line, even when its cached text is empty.
|
||||
if sc.fields.iter().any(|f| f.phase == Phase::Instr) && para.plain_text().is_empty() {
|
||||
para.mark.hidden = Some(true);
|
||||
}
|
||||
para.normalize();
|
||||
para
|
||||
}
|
||||
@@ -471,7 +478,7 @@ impl Reader<'_> {
|
||||
"w:fldSimple" => {
|
||||
let instr = k.attr("w:instr").unwrap_or("").to_string();
|
||||
let props = k.child("w:r").and_then(|r| r.child("w:rPr")).map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx));
|
||||
self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"), String::new());
|
||||
self.begin_field(sc, props, instr, on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), String::new());
|
||||
self.separate_field(sc, pb);
|
||||
self.read_inline_children(sc, pb, k, rels, ctx, depth + 1);
|
||||
self.end_field(sc, pb);
|
||||
@@ -613,6 +620,9 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:tab" | "w:ptab" => self.emit_text(sc, pb, "\t", props),
|
||||
"w:br" => {
|
||||
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
|
||||
return;
|
||||
}
|
||||
let c = match k.attr("w:type") {
|
||||
Some("page") => "\u{000C}",
|
||||
Some("column") => "\u{000E}",
|
||||
@@ -620,7 +630,11 @@ impl Reader<'_> {
|
||||
};
|
||||
self.emit_text(sc, pb, c, props);
|
||||
}
|
||||
"w:cr" => self.emit_text(sc, pb, "\n", props),
|
||||
"w:cr" => {
|
||||
if !sc.fields.iter().any(|f| f.phase == Phase::Instr) {
|
||||
self.emit_text(sc, pb, "\n", props);
|
||||
}
|
||||
}
|
||||
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
|
||||
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
|
||||
"w:sym" => {
|
||||
@@ -636,7 +650,7 @@ impl Reader<'_> {
|
||||
"w:fldChar" => match k.attr("w:fldCharType") {
|
||||
Some("begin") => {
|
||||
let form = k.child("w:ffData").map(|f| f.to_xml()).filter(|x| x.len() <= MAX_FORM).unwrap_or_default();
|
||||
self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock"), form)
|
||||
self.begin_field(sc, props.clone(), String::new(), on_off_attr(k, "w:fldLock"), on_off_attr(k, "w:dirty"), form)
|
||||
}
|
||||
Some("separate") => self.separate_field(sc, pb),
|
||||
Some("end") => self.end_field(sc, pb),
|
||||
@@ -696,7 +710,7 @@ impl Reader<'_> {
|
||||
"w:pgNum" => self.emit_obj(
|
||||
sc,
|
||||
pb,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "PAGE".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
props,
|
||||
),
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
@@ -770,7 +784,7 @@ impl Reader<'_> {
|
||||
|
||||
// ---- fields ----
|
||||
|
||||
fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool, form: String) {
|
||||
fn begin_field(&mut self, sc: &mut StoryCtx, props: CharProps, instr: String, locked: bool, dirty: bool, form: String) {
|
||||
if sc.fields.len() >= MAX_FIELD_DEPTH || sc.ignored_begins > 0 {
|
||||
sc.ignored_begins += 1;
|
||||
return;
|
||||
@@ -783,6 +797,7 @@ impl Reader<'_> {
|
||||
buf: Vec::new(),
|
||||
props,
|
||||
locked,
|
||||
dirty,
|
||||
range: false,
|
||||
sizes: Vec::new(),
|
||||
form,
|
||||
@@ -809,7 +824,7 @@ impl Reader<'_> {
|
||||
f.phase = Phase::Result;
|
||||
if !f.spilled && !nested_in_instr && is_range_instr(&f.instr) {
|
||||
f.range = true;
|
||||
let (obj, props) = (InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, f.props.clone());
|
||||
let (obj, props) = (InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, f.props.clone());
|
||||
self.emit_obj(sc, pb, obj, &props);
|
||||
}
|
||||
}
|
||||
@@ -830,7 +845,7 @@ impl Reader<'_> {
|
||||
}
|
||||
if f.phase == Phase::Instr && !nested_in_instr && is_range_instr(&f.instr) {
|
||||
// A range field with no result yet.
|
||||
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props);
|
||||
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props);
|
||||
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
|
||||
return;
|
||||
}
|
||||
@@ -842,7 +857,7 @@ impl Reader<'_> {
|
||||
// each piece stays an ordinary run with its own `w:ins` / `w:del` (#1037).
|
||||
let tracked_inside = f.buf.iter().any(|(_, p)| (p.ins, p.del) != (f.props.ins, f.props.del));
|
||||
if !nested_in_instr && (tracked_inside || f.buf.iter().any(|(it, _)| matches!(it, Item::Obj(o) if o.frame().is_some()))) {
|
||||
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked }, &f.props);
|
||||
self.emit_obj(sc, pb, InlineObject::FieldStart { instr: f.instr.trim().to_string(), locked: f.locked, dirty: f.dirty }, &f.props);
|
||||
for (it, p) in f.buf {
|
||||
match it {
|
||||
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
|
||||
@@ -864,7 +879,7 @@ impl Reader<'_> {
|
||||
Some(e) => (e.instr(), if result.is_empty() { e.text } else { result }),
|
||||
None => (f.instr.trim().to_string(), result),
|
||||
};
|
||||
let obj = InlineObject::Field { instr, result, locked: f.locked, code: false, form: f.form };
|
||||
let obj = InlineObject::Field { instr, result, locked: f.locked, dirty: f.dirty, code: false, form: f.form };
|
||||
if let Some(parent) = sc.fields.last_mut()
|
||||
&& !parent.spilled
|
||||
&& parent.phase == Phase::Instr
|
||||
@@ -885,7 +900,7 @@ impl Reader<'_> {
|
||||
f.spilled = true;
|
||||
let form = std::mem::take(&mut f.form);
|
||||
pb.push_obj(
|
||||
InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, code: false, form },
|
||||
InlineObject::Field { instr: f.instr.trim().to_string(), result: String::new(), locked: f.locked, dirty: f.dirty, code: false, form },
|
||||
&f.props,
|
||||
);
|
||||
for (it, p) in f.buf.drain(..) {
|
||||
@@ -1450,6 +1465,35 @@ impl Reader<'_> {
|
||||
extra: Default::default(),
|
||||
});
|
||||
}
|
||||
// A freeform: its `path`, in its `coordsize` space; a `v:fill` with no opacity
|
||||
// or `nf` in the path leaves it unfilled. Not WordArt (`v:textpath`).
|
||||
if shape.local() == "shape"
|
||||
&& shape.child("v:textpath").is_none()
|
||||
&& let Some(path) = shape.attr("path")
|
||||
&& let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h)
|
||||
{
|
||||
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0);
|
||||
let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0);
|
||||
let stroke = stroke.filter(|_| !vp.no_stroke);
|
||||
if stroke.is_none() && fill.is_none() {
|
||||
return None;
|
||||
}
|
||||
let mut f = vp.freeform;
|
||||
f.alpha = opacity("v:stroke");
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::Freeform,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width,
|
||||
float,
|
||||
story: None,
|
||||
freeform: Some(Arc::new(f)),
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
});
|
||||
}
|
||||
let kind = kind?;
|
||||
let mut extra = ShapeExtra::default();
|
||||
if kind == ShapeKind::Line {
|
||||
@@ -1532,6 +1576,7 @@ impl Reader<'_> {
|
||||
collect(t, "w:tr", &mut rows, &mut Vec::new(), 0);
|
||||
for (tr, row_controls) in rows.into_iter().take(MAX_ROWS) {
|
||||
let mut row = Row { props: tr.child("w:trPr").map(trpr).unwrap_or_default(), cells: Vec::new(), controls: row_controls };
|
||||
row.props.exceptions = tr.child("w:tblPrEx").map(|e| Box::new(self.pc.tblpr(e)));
|
||||
if let Some(ch) = tr.child("w:trPr").and_then(|p| p.child("w:trPrChange")) {
|
||||
let old = ch.child("w:trPr").map(trpr).unwrap_or_default();
|
||||
row.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
|
||||
@@ -12,8 +12,10 @@ mod wordart;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use wordcraft_doc::ParaProps;
|
||||
use wordcraft_doc::numbering::{Level, LevelSuffix};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TablePart};
|
||||
use wordcraft_doc::{Blocks, Document, PartKind};
|
||||
|
||||
use crate::package::{MAX_VBA_RELATED, VBA_PROJECT_PART, VBA_RELATED, rt, zip_entries};
|
||||
@@ -100,7 +102,7 @@ pub(crate) struct Writer<'d> {
|
||||
/// Writing a TOC heading: its TOC field is held back so the entries become the field's result.
|
||||
toc_hold_end: bool,
|
||||
/// The held TOC field (instruction, locked, props), opened in the first entry as Word does.
|
||||
toc_field: Option<(String, bool, wordcraft_doc::props::CharProps)>,
|
||||
toc_field: Option<(String, bool, bool, wordcraft_doc::props::CharProps)>,
|
||||
/// Open the held TOC field at the start of the paragraph being written.
|
||||
toc_begin_here: bool,
|
||||
/// Close the open TOC field at the end of the paragraph being written.
|
||||
@@ -797,49 +799,52 @@ fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
|
||||
}
|
||||
w.close("w:tblPr");
|
||||
}
|
||||
// One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders
|
||||
// and shading.
|
||||
let cond = |w: &mut W,
|
||||
ty: &str,
|
||||
chr: &wordcraft_doc::CharProps,
|
||||
fill: Option<wordcraft_doc::Rgb>,
|
||||
borders: Option<wordcraft_doc::props::Borders>| {
|
||||
if !props::has_rpr(chr) && fill.is_none() && borders.is_none() {
|
||||
return;
|
||||
// The conditional formatting parts (ECMA-376 §17.7.6): paragraph and run properties,
|
||||
// then cell borders, shading and vertical alignment. The whole table's text formatting
|
||||
// is the style's own (above).
|
||||
for part in TablePart::ALL {
|
||||
let f = t.part(part);
|
||||
let ppr = ParaProps { align: f.align, ..ParaProps::default() };
|
||||
let has_rpr = props::has_rpr(&f.chr);
|
||||
// The whole table's borders are its table borders (`w:tblPr` above).
|
||||
let borders = f.borders.filter(|_| part != TablePart::WholeTable);
|
||||
let has_tc = f.fill.is_some() || borders.is_some() || f.v_align.is_some();
|
||||
if !has_rpr && !has_tc && f.align.is_none() {
|
||||
continue;
|
||||
}
|
||||
w.open("w:tblStylePr", &[("w:type", ty)]);
|
||||
if props::has_rpr(chr) {
|
||||
w.open("w:tblStylePr", &[("w:type", part.ooxml())]);
|
||||
if f.align.is_some() {
|
||||
w.open("w:pPr", &[]);
|
||||
props::ppr_inner(w, &ppr, false, None);
|
||||
w.close("w:pPr");
|
||||
}
|
||||
if has_rpr {
|
||||
w.open("w:rPr", &[]);
|
||||
props::rpr_inner(w, chr);
|
||||
props::rpr_inner(w, &f.chr);
|
||||
w.close("w:rPr");
|
||||
}
|
||||
if fill.is_some() || borders.is_some() {
|
||||
if has_tc {
|
||||
w.open("w:tcPr", &[]);
|
||||
if let Some(bs) = &borders {
|
||||
props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]);
|
||||
if let Some(bs) = borders {
|
||||
props::borders(w, "w:tcBorders", &bs, Some("w:insideH"), &[]);
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]);
|
||||
if let Some(c) = f.fill {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &c.hex())]);
|
||||
}
|
||||
if let Some(v) = f.v_align {
|
||||
w.val(
|
||||
"w:vAlign",
|
||||
match v {
|
||||
VAlign::Top => "top",
|
||||
VAlign::Center => "center",
|
||||
VAlign::Bottom => "bottom",
|
||||
},
|
||||
);
|
||||
}
|
||||
w.close("w:tcPr");
|
||||
}
|
||||
w.close("w:tblStylePr");
|
||||
};
|
||||
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
|
||||
cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders);
|
||||
// The total row's borders, its top edge falling back to the built-in top rule.
|
||||
let total_borders = match (t.total_borders, t.total_border_top) {
|
||||
(Some(mut b), top) => {
|
||||
b.top = b.top.or(top);
|
||||
Some(b)
|
||||
}
|
||||
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
|
||||
};
|
||||
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
|
||||
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
|
||||
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
|
||||
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
|
||||
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
|
||||
}
|
||||
}
|
||||
w.close("w:style");
|
||||
}
|
||||
@@ -1049,6 +1054,10 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
|
||||
w.close(tag);
|
||||
}
|
||||
w.open("w:compat", &[]);
|
||||
// CT_Compat: the older switches come before the `w:compatSetting`s.
|
||||
if doc.settings.sum_paragraph_spacing {
|
||||
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
|
||||
}
|
||||
if s.allow_space_of_same_style_in_table {
|
||||
w.empty("w:allowSpaceOfSameStyleInTable", &[]);
|
||||
}
|
||||
|
||||
@@ -464,6 +464,9 @@ fn tblpr_inner(w: &mut W, t: &TableProps) {
|
||||
|
||||
/// `w:trPr` (when the row has any), with a tracked change last as for [`tblpr`].
|
||||
pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) {
|
||||
if let Some(x) = &r.exceptions {
|
||||
tblprex(w, x);
|
||||
}
|
||||
let change = change.zip(r.fmt_change.as_ref());
|
||||
let gaps = r.grid_before > 0 || r.grid_after > 0 || r.w_before.is_some() || r.w_after.is_some();
|
||||
if r.height.is_none() && !r.header && !r.cant_split && !gaps && change.is_none() {
|
||||
@@ -477,6 +480,35 @@ pub fn trpr(w: &mut W, r: &RowProps, change: Option<&ChangeAttrs>) {
|
||||
w.close("w:trPr");
|
||||
}
|
||||
|
||||
/// `w:tblPrEx`: only the properties the row overrides (it takes no style, float or look).
|
||||
fn tblprex(w: &mut W, t: &TableProps) {
|
||||
w.open("w:tblPrEx", &[]);
|
||||
if let Some(p) = t.width_pct {
|
||||
w.empty("w:tblW", &[("w:w", &n(round(p.clamp(0.0, 1000.0) * 50.0))), ("w:type", "pct")]);
|
||||
} else if let Some(v) = t.width {
|
||||
w.empty("w:tblW", &[("w:w", &twips(v.max(0.0))), ("w:type", "dxa")]);
|
||||
}
|
||||
if let Some(a) = t.align {
|
||||
w.val("w:jc", align_val(if a == Align::Justify || a == Align::Distribute { Align::Left } else { a }));
|
||||
}
|
||||
if let Some(i) = t.indent {
|
||||
w.empty("w:tblInd", &[("w:w", &twips(i)), ("w:type", "dxa")]);
|
||||
}
|
||||
if let Some(b) = &t.borders {
|
||||
borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
|
||||
}
|
||||
if let Some(s) = t.shading {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]);
|
||||
}
|
||||
if t.fixed {
|
||||
w.empty("w:tblLayout", &[("w:type", "fixed")]);
|
||||
}
|
||||
if let Some(m) = &t.cell_margins {
|
||||
margins(w, "w:tblCellMar", m);
|
||||
}
|
||||
w.close("w:tblPrEx");
|
||||
}
|
||||
|
||||
fn trpr_inner(w: &mut W, r: &RowProps) {
|
||||
// The grid gaps go first, in the schema's order (as Word writes them).
|
||||
if r.grid_before > 0 {
|
||||
@@ -542,13 +574,14 @@ fn tcpr_inner(w: &mut W, c: &CellProps) {
|
||||
if let Some(m) = &c.margins {
|
||||
margins(w, "w:tcMar", m);
|
||||
}
|
||||
if c.text_direction.is_turned() {
|
||||
if c.text_direction.is_turned() || c.text_direction_set {
|
||||
w.val("w:textDirection", c.text_direction.ooxml());
|
||||
}
|
||||
match c.valign {
|
||||
VAlign::Top => {}
|
||||
VAlign::Center => w.val("w:vAlign", "center"),
|
||||
VAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
None => {}
|
||||
Some(VAlign::Top) => w.val("w:vAlign", "top"),
|
||||
Some(VAlign::Center) => w.val("w:vAlign", "center"),
|
||||
Some(VAlign::Bottom) => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if c.hide_mark {
|
||||
w.empty("w:hideMark", &[]);
|
||||
|
||||
@@ -224,10 +224,10 @@ impl Writer<'_> {
|
||||
}
|
||||
// Taken before the content so a nested story (a text box in an entry) can't claim them.
|
||||
if std::mem::take(&mut self.toc_begin_here)
|
||||
&& let Some((instr, locked, props)) = self.toc_field.take()
|
||||
&& let Some((instr, locked, dirty, props)) = self.toc_field.take()
|
||||
{
|
||||
self.rev_open(w, &props);
|
||||
self.field_start(w, &instr, locked, "", &props);
|
||||
self.field_start(w, &instr, locked, dirty, "", &props);
|
||||
self.rev_close(w, &props);
|
||||
}
|
||||
let toc_end = std::mem::take(&mut self.toc_end_here);
|
||||
@@ -496,10 +496,10 @@ impl Writer<'_> {
|
||||
None => super::math::write_equation(w, linear, *display, math, color),
|
||||
}
|
||||
}
|
||||
InlineObject::Field { instr, result, locked, form, .. } => self.field(w, instr, result, *locked, form, props),
|
||||
InlineObject::FieldStart { instr, locked } => {
|
||||
InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props),
|
||||
InlineObject::FieldStart { instr, locked, dirty } => {
|
||||
self.rev_open(w, props);
|
||||
self.field_start(w, instr, *locked, "", props);
|
||||
self.field_start(w, instr, *locked, *dirty, "", props);
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::FieldEnd => {
|
||||
@@ -795,11 +795,11 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, form: &str, props: &CharProps) {
|
||||
fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) {
|
||||
if self.toc_hold_end && is_toc(instr) {
|
||||
// A TOC heading's field opens in the first entry and closes after the last (see `toc_span`).
|
||||
self.toc_hold_end = false;
|
||||
self.toc_field = Some((instr.to_string(), locked, props.clone()));
|
||||
self.toc_field = Some((instr.to_string(), locked, dirty, props.clone()));
|
||||
return;
|
||||
}
|
||||
if let Some(e) = wordcraft_doc::eq::Enclosure::parse(instr) {
|
||||
@@ -816,7 +816,7 @@ impl Writer<'_> {
|
||||
w.close("w:r");
|
||||
};
|
||||
self.rev_open(w, props);
|
||||
self.field_start(w, instr, locked, form, props);
|
||||
self.field_start(w, instr, locked, dirty, form, props);
|
||||
if !result.is_empty() {
|
||||
run(w, &|w| {
|
||||
let mut buf = String::new();
|
||||
@@ -896,7 +896,7 @@ impl Writer<'_> {
|
||||
/// A field's begin, instruction and separate runs.
|
||||
/// A field's begin, code and separator; `form` is a legacy form field's `w:ffData`, which
|
||||
/// belongs inside the begin.
|
||||
fn field_start(&self, w: &mut W, instr: &str, locked: bool, form: &str, props: &CharProps) {
|
||||
fn field_start(&self, w: &mut W, instr: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) {
|
||||
let del = self.is_del(props);
|
||||
let run = |w: &mut W, f: &dyn Fn(&mut W)| {
|
||||
w.open("w:r", &[]);
|
||||
@@ -905,11 +905,17 @@ impl Writer<'_> {
|
||||
w.close("w:r");
|
||||
};
|
||||
run(w, &|w| {
|
||||
let attrs: &[(&str, &str)] = if locked { &[("w:fldCharType", "begin"), ("w:fldLock", "1")] } else { &[("w:fldCharType", "begin")] };
|
||||
let mut attrs: Vec<(&str, &str)> = vec![("w:fldCharType", "begin")];
|
||||
if locked {
|
||||
attrs.push(("w:fldLock", "1"));
|
||||
}
|
||||
if dirty {
|
||||
attrs.push(("w:dirty", "true"));
|
||||
}
|
||||
if form.is_empty() {
|
||||
w.empty("w:fldChar", attrs);
|
||||
w.empty("w:fldChar", &attrs);
|
||||
} else {
|
||||
w.open("w:fldChar", attrs);
|
||||
w.open("w:fldChar", &attrs);
|
||||
w.raw(form);
|
||||
w.close("w:fldChar");
|
||||
}
|
||||
@@ -985,7 +991,9 @@ impl Writer<'_> {
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => "rect",
|
||||
ShapeKind::RoundedRectangle => "roundRect",
|
||||
ShapeKind::Ellipse => "ellipse",
|
||||
ShapeKind::FlowchartOr => "flowChartOr",
|
||||
ShapeKind::Triangle => "triangle",
|
||||
ShapeKind::RightTriangle => "rtTriangle",
|
||||
ShapeKind::Diamond => "diamond",
|
||||
ShapeKind::Line => "line",
|
||||
ShapeKind::StraightConnector => "straightConnector1",
|
||||
@@ -1361,7 +1369,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:lnNumType", &refs);
|
||||
}
|
||||
if s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
|
||||
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
|
||||
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(st) = s.page_num_start {
|
||||
a.push(("w:start", st.to_string()));
|
||||
@@ -1388,15 +1396,19 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:cols", &refs);
|
||||
}
|
||||
if let Some(on) = s.form_prot {
|
||||
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
match s.valign {
|
||||
wordcraft_doc::props::VAlign::Top => {}
|
||||
wordcraft_doc::props::VAlign::Center => w.val("w:vAlign", "center"),
|
||||
wordcraft_doc::props::VAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
wordcraft_doc::section::PageVAlign::Top => {}
|
||||
wordcraft_doc::section::PageVAlign::Center => w.val("w:vAlign", "center"),
|
||||
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
|
||||
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if s.title_page {
|
||||
w.empty("w:titlePg", &[]);
|
||||
}
|
||||
if s.text_direction.is_turned() {
|
||||
if s.text_direction.is_turned() || s.text_direction_set {
|
||||
w.val("w:textDirection", s.text_direction.ooxml());
|
||||
}
|
||||
if s.rtl {
|
||||
|
||||
@@ -190,7 +190,7 @@ fn orphan_markers_from_editing_are_dropped_on_save() {
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(0, InlineObject::FieldEnd, &c).unwrap();
|
||||
d.para_mut(StoryRef::Body, &Path::top(1))
|
||||
.unwrap()
|
||||
.insert_object(1, InlineObject::FieldStart { instr: "ADDIN X".into(), locked: true }, &c)
|
||||
.insert_object(1, InlineObject::FieldStart { instr: "ADDIN X".into(), locked: true, dirty: false }, &c)
|
||||
.unwrap();
|
||||
let xml = document_xml(&write(&d).unwrap());
|
||||
assert_eq!(count(&xml, "begin"), 0);
|
||||
@@ -204,7 +204,7 @@ fn programmatic_range_field_writes_as_word_field() {
|
||||
let mut d = Document::from_text("ab");
|
||||
let c = CharProps::default();
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_object(1, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM {}".into(), locked: false }, &c).unwrap();
|
||||
p.insert_object(1, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM {}".into(), locked: false, dirty: false }, &c).unwrap();
|
||||
let end = 1 + OBJ.len_utf8() + 1;
|
||||
p.insert_object(end, InlineObject::FieldEnd, &c).unwrap();
|
||||
d.set_custom_prop("ZOTERO_PREF_1", "<data/>");
|
||||
|
||||
@@ -115,14 +115,28 @@ fn single_entry_toc_keeps_formatted_result_and_nested_page_field() {
|
||||
assert_eq!(p[0].plain_text(), "Contents");
|
||||
assert_eq!(
|
||||
p[0].objects,
|
||||
vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false, form: String::new() }]
|
||||
vec![InlineObject::Field {
|
||||
instr: r#"TOC \o "1-1" \h"#.into(),
|
||||
result: String::new(),
|
||||
locked: true,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new()
|
||||
}]
|
||||
);
|
||||
assert_eq!(p[1].plain_text(), "Intro\t1");
|
||||
assert_eq!(p[1].props_of_char(0).bold, Some(true));
|
||||
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1"));
|
||||
assert_eq!(
|
||||
p[1].objects,
|
||||
vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false, form: String::new() }]
|
||||
vec![InlineObject::Field {
|
||||
instr: r#"PAGEREF _Toc1 \h"#.into(),
|
||||
result: "1".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,10 +155,17 @@ fn toc_complex_field_spanning_paragraphs() {
|
||||
let p = paras(&d);
|
||||
assert_eq!(p.len(), 5);
|
||||
assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}");
|
||||
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false });
|
||||
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: false });
|
||||
assert_eq!(
|
||||
p[0].objects[1],
|
||||
InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false, form: String::new() }
|
||||
InlineObject::Field {
|
||||
instr: r#"PAGEREF _Toc1 \h"#.into(),
|
||||
result: "1".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new()
|
||||
}
|
||||
);
|
||||
assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1"));
|
||||
assert_eq!(p[0].props.style.as_deref(), Some("TOC1"));
|
||||
@@ -153,7 +174,10 @@ fn toc_complex_field_spanning_paragraphs() {
|
||||
assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]);
|
||||
assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]);
|
||||
assert_eq!(p[4].plain_text(), "Page 7");
|
||||
assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false, form: String::new() });
|
||||
assert_eq!(
|
||||
p[4].objects[0],
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() }
|
||||
);
|
||||
// And it writes back out and reads the same.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(again.body, d.body);
|
||||
@@ -659,12 +683,54 @@ fn unbalanced_fields_and_markers() {
|
||||
assert_eq!(p[0].text, "a");
|
||||
assert_eq!(
|
||||
p[1].objects,
|
||||
vec![InlineObject::Field { instr: "IF X = \"y\"".into(), result: String::new(), locked: false, code: false, form: String::new() }]
|
||||
vec![InlineObject::Field {
|
||||
instr: "IF X = \"y\"".into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new()
|
||||
}]
|
||||
);
|
||||
assert_eq!(p[2].objects, vec![InlineObject::BookmarkStart { name: "between".into() }, InlineObject::BookmarkEnd { name: "between".into() }]);
|
||||
assert_eq!(p[2].plain_text(), "after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_code_spans_paragraphs() {
|
||||
let body = r#"
|
||||
<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>IF</w:instrText></w:r></w:p>
|
||||
<w:p><w:r><w:instrText> MERGEFIELD x</w:instrText><w:br/><w:instrText> = "y"</w:instrText></w:r></w:p>
|
||||
<w:p><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>
|
||||
<w:p><w:r><w:t>Visible</w:t></w:r></w:p>"#;
|
||||
let doc = read_body(body);
|
||||
let p = paras(&doc);
|
||||
assert_eq!(p.len(), 4);
|
||||
for code in &p[..2] {
|
||||
assert!(code.plain_text().is_empty());
|
||||
assert_eq!(code.mark.hidden, Some(true));
|
||||
}
|
||||
assert_eq!(p[2].mark.hidden, None);
|
||||
assert_eq!(p[3].plain_text(), "Visible");
|
||||
assert_eq!(p[3].mark.hidden, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_field_results_keep_line() {
|
||||
let body = r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:fldChar w:fldCharType="end"/></w:r></w:p>
|
||||
<w:p><w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText></w:r></w:p>
|
||||
<w:p/>
|
||||
<w:p><w:r><w:instrText> x = y</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>Result</w:t><w:fldChar w:fldCharType="end"/></w:r></w:p>"#;
|
||||
let doc = read_body(body);
|
||||
let p = paras(&doc);
|
||||
assert_eq!(p[0].mark.hidden, None);
|
||||
assert!(matches!(&p[0].objects[0], InlineObject::Field { instr, .. } if instr == "PAGE"));
|
||||
assert_eq!(p[1].mark.hidden, Some(true));
|
||||
assert_eq!(p[2].mark.hidden, Some(true));
|
||||
assert_eq!(p[3].mark.hidden, None);
|
||||
assert_eq!(p[3].plain_text(), "Result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_self_reference_and_escaping_targets() {
|
||||
let hdr = format!(
|
||||
@@ -1732,7 +1798,7 @@ fn includepicture_keeps_its_embedded_picture() {
|
||||
let p = paras(d);
|
||||
let objs = &p[0].objects;
|
||||
assert_eq!(objs.len(), 3, "{objs:?}");
|
||||
assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked });
|
||||
assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked, dirty: false });
|
||||
let InlineObject::Image { media, .. } = &objs[1] else { panic!("{objs:?}") };
|
||||
assert_eq!(d.media.get(media).unwrap().as_slice(), png);
|
||||
assert_eq!(objs[2], InlineObject::FieldEnd);
|
||||
@@ -1924,8 +1990,9 @@ fn vml_preset_shapes_are_drawn() {
|
||||
assert_eq!(shape(3), Some((ShapeKind::Triangle, 20.0, 10.0, None, black, 0.75, true, false)));
|
||||
// A preset holding text keeps its geometry around the text.
|
||||
assert_eq!(shape(4), Some((ShapeKind::Diamond, 50.0, 50.0, Some(Rgb(0xFF, 0xFF, 0)), black, 0.75, true, true)));
|
||||
assert_eq!(shape(5), Some((ShapeKind::Triangle, 20.0, 20.0, Some(Rgb(0xFF, 0, 0)), black, 0.75, true, false)));
|
||||
assert_eq!(shape(6), None);
|
||||
assert_eq!(shape(5), Some((ShapeKind::RightTriangle, 20.0, 20.0, Some(Rgb(0xFF, 0, 0)), black, 0.75, true, false)));
|
||||
// Its own path is drawn as a freeform, not as the shapetype's arrow.
|
||||
assert_eq!(kind(6), Some((ShapeKind::Freeform, false)));
|
||||
assert_eq!(shape(7), Some((ShapeKind::Line, 40.0, 10.0, None, black, 0.75, true, false)));
|
||||
assert_eq!(shape(8), Some((ShapeKind::Line, 30.0, 15.0, None, Some(Rgb(0xFF, 0, 0)), 0.75, false, false)));
|
||||
assert!(matches!(ps[8].objects.first(), Some(InlineObject::Shape { extra, .. }) if extra.arrow_end && !extra.arrow_start));
|
||||
@@ -2567,6 +2634,148 @@ fn text_frames_read_and_round_trip() {
|
||||
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
|
||||
}
|
||||
|
||||
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header,
|
||||
/// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a
|
||||
/// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back.
|
||||
#[test]
|
||||
fn vml_freeform_rule_in_header() {
|
||||
let w10 = r#"xmlns:w10="urn:schemas-microsoft-com:office:word""#;
|
||||
let rule = r##"<w:p><w:r><w:pict><v:shape style="position:absolute;margin-left:0;margin-top:30pt;width:360pt;height:12pt;z-index:2;mso-position-horizontal-relative:margin;mso-position-vertical-relative:margin" coordsize="1000,1000" path="m,500l1000,500xe" fillcolor="this" stroked="t" strokecolor="#3366cc" strokeweight="1.5pt"><v:fill opacity="0"/><w10:wrap anchorx="margin"/></v:shape></w:pict></w:r>
|
||||
<w:r><w:pict><v:shape style="width:20pt;height:10pt" coordorigin="100,100" coordsize="200,100" path="t100,100r100,0,0,100nfx e" strokecolor="red"><v:stroke opacity="32768f"/></v:shape></w:pict></w:r></w:p>"##;
|
||||
let header = format!(r#"<w:hdr {W_NS} {w10}><w:p><w:r><w:t>Header</w:t></w:r></w:p>{rule}</w:hdr>"#);
|
||||
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
|
||||
let bytes = docx(body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)]);
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
|
||||
let check = |d: &Document| {
|
||||
let hid = d.last_section.headers.default.unwrap();
|
||||
let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect();
|
||||
let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") };
|
||||
let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") };
|
||||
assert_eq!((*kind, *fill, *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
|
||||
assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}");
|
||||
assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText));
|
||||
assert!(near(float.y, 30.0), "{float:?}");
|
||||
let [p] = f.paths.as_slice() else { panic!("{f:?}") };
|
||||
let pts = f.placed(0.0, 0.0, *w, *h);
|
||||
let [(pts, _)] = pts.as_slice() else { panic!("{pts:?}") };
|
||||
assert!(p.closed && pts.len() == 2, "{pts:?}");
|
||||
assert!(near(pts[0][0], 0.0) && near(pts[0][1], 6.0) && near(pts[1][0], 360.0) && near(pts[1][1], 6.0), "{pts:?}");
|
||||
// Relative moves and lines from the origin, `nf`: an outlined triangle, half opaque.
|
||||
let InlineObject::Shape { kind: ShapeKind::Freeform, fill: None, stroke: Some(Rgb(255, 0, 0)), w, h, freeform: Some(f), .. } = tri else {
|
||||
panic!("{tri:?}")
|
||||
};
|
||||
let placed = f.placed(0.0, 0.0, *w, *h);
|
||||
let [(pts, true)] = placed.as_slice() else { panic!("{placed:?}") };
|
||||
let want = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]];
|
||||
assert!(pts.len() == 3 && pts.iter().zip(want).all(|(a, b)| near(a[0], b[0]) && near(a[1], b[1])), "{pts:?}");
|
||||
assert!(near(f.alpha, 0.5), "{}", f.alpha);
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// Hostile VML paths and coordinate spaces read without panicking and stay finite.
|
||||
#[test]
|
||||
fn hostile_vml_paths_stay_finite() {
|
||||
let paths = [
|
||||
"m,",
|
||||
"l",
|
||||
"mxe",
|
||||
"m99999999999999999999999999,0l-99999999999999999999,1e",
|
||||
"minf,nanlNaN,-inf",
|
||||
"m@1,@2l@3,@4nfnse",
|
||||
"wa1",
|
||||
"c1",
|
||||
"m١٢,٣l٤,٥",
|
||||
"m0,0c",
|
||||
"m0,0v1,2,3",
|
||||
"ar0,0,10,10,0,0,10,10at",
|
||||
"m0,0r1,1,1,1,1,1,1,1,1,1x x x l",
|
||||
"--..,,@@ ,,, xm1.5.5,2..3l+-+,-e",
|
||||
"\u{1F600}m1,1l2,2",
|
||||
"qbqxqyaeal1,2,3,4,5,6",
|
||||
];
|
||||
let spaces = [("0,0", "0,0"), ("-5,-5", "x,y"), ("1e39,x", "-99999999999999999999,99999999999999999999"), ("", ""), ("1,1", "1e308,1e308")];
|
||||
for path in paths {
|
||||
for (size, origin) in spaces {
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:pict><v:shape style="width:1e30pt;height:-4pt" coordsize="{size}" coordorigin="{origin}" path="{path}" strokecolor="black"/></w:pict></w:r></w:p>"#
|
||||
);
|
||||
let d = read_body(&body);
|
||||
// A path with formulas isn't drawn (they aren't evaluated).
|
||||
if path.contains('@') {
|
||||
assert!(paras(&d)[0].objects.iter().all(|o| !matches!(o, InlineObject::Shape { freeform: Some(_), .. })), "{path}");
|
||||
}
|
||||
for o in ¶s(&d)[0].objects {
|
||||
if let InlineObject::Shape { w, h, freeform: Some(f), .. } = o {
|
||||
assert!(w.is_finite() && h.is_finite() && f.alpha.is_finite(), "{path}");
|
||||
assert!(f.paths.iter().flat_map(|p| &p.pts).all(|[x, y]| x.is_finite() && y.is_finite()), "{path} {size} {origin}");
|
||||
for (pts, _) in f.placed(0.0, 0.0, *w, *h) {
|
||||
assert!(pts.iter().all(|[x, y]| x.is_finite() && y.is_finite()), "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
wordcraft_docx::write(&d).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measured in Word: `w:asciiTheme="minorBidi"` names the theme's complex-script font (`a:cs`);
|
||||
/// when that is empty it names nothing, and the run keeps the font it inherits.
|
||||
#[test]
|
||||
fn bidi_theme_slots_name_the_complex_script_font() {
|
||||
let font = |cs: &str| {
|
||||
let theme = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Cambria"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#
|
||||
);
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml")];
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="minorBidi" w:hAnsiTheme="minorBidi"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &rels, &[("word/theme/theme1.xml", &theme)])).unwrap();
|
||||
paras(&d)[0].runs[0].props.font.clone()
|
||||
};
|
||||
assert_eq!(font(""), None);
|
||||
assert_eq!(font("Times New Roman").as_deref(), Some("Times New Roman"));
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
#[test]
|
||||
fn roman_fonts_are_read_from_the_font_table() {
|
||||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Brand Serif"><w:family w:val="roman"/></w:font><w:font w:name="Brand Sans"><w:family w:val="swiss"/></w:font></w:fonts>"#;
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||||
}
|
||||
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||||
#[test]
|
||||
fn html_auto_spacing_off_is_read_and_kept() {
|
||||
let settings = |compat: &str| {
|
||||
let s = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}><w:compat>{compat}</w:compat></w:settings>"#);
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/settings.xml", &s)])).unwrap();
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
(d.settings.sum_paragraph_spacing, again.settings.sum_paragraph_spacing)
|
||||
};
|
||||
assert_eq!(settings("<w:doNotUseHTMLParagraphAutoSpacing/>"), (true, true));
|
||||
assert_eq!(settings(""), (false, false));
|
||||
}
|
||||
|
||||
/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white
|
||||
/// corner cut-outs drawn as isosceles triangles pointed the wrong way.
|
||||
#[test]
|
||||
fn a_right_triangle_keeps_its_right_angle() {
|
||||
let body = anchored_wsp("Corner", 1270000, 635000, r#"<a:prstGeom prst="rtTriangle"><a:avLst/></a:prstGeom>"#, "", "<wps:cNvSpPr/>");
|
||||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||||
let kind = |d: &Document| match ¶s(d)[0].objects[0] {
|
||||
InlineObject::Shape { kind, .. } => *kind,
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
assert_eq!(kind(&d), ShapeKind::RightTriangle);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
assert_eq!(kind(&wordcraft_docx::read(&saved).unwrap()), ShapeKind::RightTriangle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_with_empty_target_is_no_link() {
|
||||
// LibreOffice writes `Target=""` for the Hyperlink character style without an address (#1005).
|
||||
@@ -2605,3 +2814,19 @@ fn hyperlink_with_empty_target_is_no_link() {
|
||||
assert_eq!(p.props_of_char(0).link, None);
|
||||
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_table_borders_round_trip() {
|
||||
let d = read_body(
|
||||
r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
|
||||
<w:tr><w:tblPrEx><w:tblBorders><w:top w:val="single" w:sz="8"/><w:insideV w:val="single" w:sz="4"/></w:tblBorders></w:tblPrEx>
|
||||
<w:trPr><w:trHeight w:val="400"/></w:trPr><w:tc><w:p/></w:tc><w:tc><w:p/></w:tc></w:tr>
|
||||
<w:tr><w:tc><w:p/></w:tc><w:tc><w:p/></w:tc></w:tr></w:tbl><w:p/>"#,
|
||||
);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = text(&unzip(&saved), "word/document.xml");
|
||||
assert!(xml.contains("<w:tblPrEx>"), "row table borders were dropped: {xml}");
|
||||
assert!(xml.contains("<w:insideV"));
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(d.body, again.body);
|
||||
}
|
||||
+145
-12
@@ -8,7 +8,7 @@ use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
|
||||
@@ -286,7 +286,7 @@ fn tables_with_merges_round_trip() {
|
||||
{
|
||||
let c = &mut t.rows[1].cells[0];
|
||||
c.props.shading = Some(Rgb(0xFF, 0, 0));
|
||||
c.props.valign = VAlign::Bottom;
|
||||
c.props.valign = Some(VAlign::Bottom);
|
||||
c.props.text_direction = TextDirection::Up;
|
||||
c.props.no_wrap = true;
|
||||
c.props.margins = Some([2.0, 3.0, 4.0, 5.0]);
|
||||
@@ -324,7 +324,7 @@ fn sections_headers_footers_round_trip() {
|
||||
let mut p = Paragraph::with_text("Page ", CharProps::default());
|
||||
p.insert_object(
|
||||
5,
|
||||
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -338,7 +338,7 @@ fn sections_headers_footers_round_trip() {
|
||||
title_page: true,
|
||||
page_num_start: Some(3),
|
||||
page_num_format: NumFormat::UpperRoman,
|
||||
valign: VAlign::Center,
|
||||
valign: PageVAlign::Center,
|
||||
page_borders: Some(Borders::box_(Border::single(1.0))),
|
||||
line_numbers: Some(LineNumbering { count_by: 5, start: 1, distance: 18.0, restart: LineNumberRestart::Section }),
|
||||
gutter: 18.0,
|
||||
@@ -374,7 +374,10 @@ fn sections_headers_footers_round_trip() {
|
||||
assert_eq!(part_text(&r, secs[0].1.headers.first), "First page header");
|
||||
assert_eq!(secs[0].1.headers.default, secs[1].1.headers.default, "shared header part stays shared");
|
||||
let fp = r.parts.get(&secs[0].1.footers.default.unwrap()).unwrap().blocks[0].as_para().unwrap();
|
||||
assert_eq!(fp.objects, vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() }]);
|
||||
assert_eq!(
|
||||
fp.objects,
|
||||
vec![InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }]
|
||||
);
|
||||
assert_eq!(part_text(&r, secs[1].1.footers.default), "Page 1");
|
||||
assert_eq!(r.parts.get(&secs[0].1.headers.default.unwrap()).unwrap().kind, PartKind::Header);
|
||||
}
|
||||
@@ -551,6 +554,49 @@ fn table_style_formatting_round_trips() {
|
||||
assert_eq!(got.table.as_ref(), Some(&parts));
|
||||
}
|
||||
|
||||
/// #714: every table style part (ECMA-376 §17.7.6 `w:tblStylePr` types) keeps its run
|
||||
/// formatting, cell shading, cell borders and horizontal and vertical alignment through a save.
|
||||
#[test]
|
||||
fn every_table_style_part_round_trips() {
|
||||
use wordcraft_doc::styles::{PartFormat, TablePart};
|
||||
let mut d = doc_with(vec![Paragraph::with_text("x", CharProps::default())]);
|
||||
let mut st = Style { id: "Parts".into(), name: "Parts".into(), kind: StyleKind::Table, based_on: Some("TableGrid".into()), ..Default::default() };
|
||||
let aligns = [Align::Left, Align::Center, Align::Right];
|
||||
let valigns = [VAlign::Top, VAlign::Center, VAlign::Bottom];
|
||||
for (i, part) in TablePart::ALL.into_iter().enumerate() {
|
||||
let n = i as u8;
|
||||
let f = PartFormat {
|
||||
chr: CharProps {
|
||||
bold: Some(i % 2 == 0),
|
||||
italic: Some(i % 3 == 0),
|
||||
underline: (i % 4 == 0).then_some(Underline::Single),
|
||||
size: Some(8.0 + i as f32),
|
||||
font: Some(format!("Face {i}")),
|
||||
color: Some(TextColor::Rgb(Rgb(n, 0x20, 0x40))),
|
||||
..Default::default()
|
||||
},
|
||||
fill: Some(Rgb(0x10 + n, 0x80, 0xF0 - n)),
|
||||
borders: Some(Borders {
|
||||
top: Some(Border { style: BorderStyle::Double, width: 1.5, color: Some(Rgb(n, 0, 0)), space: 0.0 }),
|
||||
bottom: Some(Border::single(0.5 + i as f32 / 4.0)),
|
||||
left: Some(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 }),
|
||||
inside_v: (i % 2 == 1).then(|| Border::single(0.25)),
|
||||
..Default::default()
|
||||
}),
|
||||
align: aligns.get(i % 3).copied(),
|
||||
v_align: valigns.get(i % 3).copied(),
|
||||
};
|
||||
st.set_table_part(part, f);
|
||||
}
|
||||
d.styles.upsert(st.clone());
|
||||
let r = rt(&d);
|
||||
let got = r.styles.get("Parts").unwrap();
|
||||
for part in TablePart::ALL {
|
||||
assert_eq!(got.table_part(part), st.table_part(part), "{}", part.ooxml());
|
||||
}
|
||||
assert_eq!(got.table, st.table);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_round_trip() {
|
||||
let mut d = Document::new();
|
||||
@@ -731,6 +777,7 @@ fn toc_field_wraps_its_entries() {
|
||||
instr: "TOC \\o \"1-2\" \\h \\z \\u".into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
@@ -818,7 +865,14 @@ fn toc_field_skips_nested_stories() {
|
||||
let end = head.len();
|
||||
head.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "TOC \\o \"1-1\" \\h \\z \\u".into(), result: String::new(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field {
|
||||
instr: "TOC \\o \"1-1\" \\h \\z \\u".into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -857,8 +911,12 @@ fn tracked_changes_round_trip() {
|
||||
let p = para_runs(&[("kept ", CharProps::default()), ("added\t", ins.clone()), ("removed", del.clone()), ("gone", both.clone())]);
|
||||
let mut p = p;
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false, code: false, form: String::new() }, &del)
|
||||
.unwrap();
|
||||
p.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "DATE".into(), result: "today".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&del,
|
||||
)
|
||||
.unwrap();
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let r = rt(&d);
|
||||
assert_eq!(r.revisions, d.revisions);
|
||||
@@ -905,9 +963,16 @@ fn tracked_paragraph_marks_round_trip() {
|
||||
fn fields_and_special_chars_round_trip() {
|
||||
let mut p = Paragraph::with_text("a\tb\nc\u{000C}d\u{000E}e\u{2011}f\u{00AD}g spaced ", CharProps::default());
|
||||
let fields = [
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "DATE \\@ \"M/d/yyyy\"".into(), result: "1/2/2026".into(), locked: true, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "4".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
InlineObject::Field {
|
||||
instr: "DATE \\@ \"M/d/yyyy\"".into(),
|
||||
result: "1/2/2026".into(),
|
||||
locked: true,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
];
|
||||
for f in &fields {
|
||||
let end = p.len();
|
||||
@@ -920,6 +985,74 @@ fn fields_and_special_chars_round_trip() {
|
||||
assert_eq!(got.runs, p.runs);
|
||||
}
|
||||
|
||||
/// The `document.xml` of a written package.
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
/// #877: a field begin marker's `w:dirty` (the cached result is stale; Word refreshes it on
|
||||
/// open) survives a round trip, and a field without the flag doesn't gain one. Generators that
|
||||
/// can't paginate (pandoc-style pipelines, python-docx templates) set the flag instead of a result.
|
||||
#[test]
|
||||
fn field_dirty_flag_round_trips() {
|
||||
let mut p = Paragraph::with_text("", CharProps::default());
|
||||
for instr in ["PAGE", "DATE"] {
|
||||
let end = p.len();
|
||||
p.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: instr.into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
|
||||
// Another producer marks the first field's begin as dirty.
|
||||
let xml = document_xml(&bytes);
|
||||
let marked = xml.replacen(r#"<w:fldChar w:fldCharType="begin"/>"#, r#"<w:fldChar w:fldCharType="begin" w:dirty="true"/>"#, 1);
|
||||
assert_ne!(&marked, &xml, "the written begin marker must be bare: {xml}");
|
||||
let r = wordcraft_docx::read(&replace_part(&bytes, "word/document.xml", marked.as_bytes())).expect("read");
|
||||
let out = document_xml(&wordcraft_docx::write(&r).expect("write"));
|
||||
assert_eq!(out.matches("w:dirty").count(), 1, "the dirty field keeps its flag, the other gains none: {out}");
|
||||
assert!(out.contains(r#"<w:fldChar w:fldCharType="begin" w:dirty="true"/>"#), "{out}");
|
||||
}
|
||||
|
||||
/// #877: a TOC imported as a range field keeps `w:dirty` through a round trip — the flag sits on
|
||||
/// the begin marker Word rebuilds the field from (#878's fixture shape: a placeholder result).
|
||||
#[test]
|
||||
fn range_toc_dirty_flag_round_trips() {
|
||||
let mut toc = Paragraph::new();
|
||||
toc.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: false }, &CharProps::default())
|
||||
.unwrap();
|
||||
let end = toc.len();
|
||||
toc.insert_text(end, "Placeholder: update fields (F9).", &CharProps::default()).unwrap();
|
||||
let end = toc.len();
|
||||
toc.insert_object(end, InlineObject::FieldEnd, &CharProps::default()).unwrap();
|
||||
let mut blocks = vec![toc];
|
||||
for (t, style) in [("Alpha", Some("Heading1")), ("Body text.", None), ("Beta", Some("Heading2")), ("Gamma", Some("Heading1"))] {
|
||||
let mut h = Paragraph::with_text(t, CharProps::default());
|
||||
h.props.style = style.map(str::to_string);
|
||||
blocks.push(h);
|
||||
}
|
||||
let bytes = wordcraft_docx::write(&doc_with(blocks)).expect("write");
|
||||
let xml = document_xml(&bytes);
|
||||
let marked = xml.replacen(r#"<w:fldChar w:fldCharType="begin"/>"#, r#"<w:fldChar w:fldCharType="begin" w:dirty="true"/>"#, 1);
|
||||
assert_ne!(&marked, &xml, "the written begin marker must be bare: {xml}");
|
||||
let marked = replace_part(&bytes, "word/document.xml", marked.as_bytes());
|
||||
let r = wordcraft_docx::read(&marked).expect("read");
|
||||
// Still the imported shape: a range field holding the placeholder as ordinary content.
|
||||
assert!(matches!(paras(&r)[0].objects.first(), Some(InlineObject::FieldStart { .. })), "{:?}", paras(&r)[0].objects);
|
||||
assert!(paras(&r)[0].objects.last().is_some_and(|o| matches!(o, InlineObject::FieldEnd)));
|
||||
assert_eq!(paras(&r)[0].plain_text(), "Placeholder: update fields (F9).");
|
||||
let out = document_xml(&wordcraft_docx::write(&r).expect("write"));
|
||||
assert_eq!(out.matches("w:dirty").count(), 1, "the TOC keeps its flag: {out}");
|
||||
let (begin, placeholder) = (out.find("fldCharType=\"begin\"").unwrap(), out.find("Placeholder").unwrap());
|
||||
let (separate, end) = (out.find("fldCharType=\"separate\"").unwrap(), out.find("fldCharType=\"end\"").unwrap());
|
||||
assert!(begin < separate && separate < placeholder && placeholder < end, "the placeholder stays the field's result: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_textboxes_equations_dropcaps_round_trip() {
|
||||
let mut d = Document::new();
|
||||
@@ -2187,7 +2320,7 @@ fn enclosed_characters_round_trip_in_words_structure() {
|
||||
let off = p.len();
|
||||
p.insert_object(
|
||||
off,
|
||||
InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: e.instr(), result: "字".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps { size, ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Row-level table property exceptions (`w:tblPrEx` in `w:tr`, ECMA-376 Part 1 §17.4.61).
|
||||
//! Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn row(ex: &str, text: &str) -> String {
|
||||
format!(
|
||||
r#"<w:tr>{ex}<w:trPr><w:cantSplit/></w:trPr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:tc></w:tr>"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_property_exceptions_round_trip_before_the_row_properties() {
|
||||
let ex = r#"<w:tblPrEx><w:tblW w:w="3000" w:type="dxa"/><w:jc w:val="center"/><w:tblInd w:w="240" w:type="dxa"/><w:tblLayout w:type="fixed"/></w:tblPrEx>"#;
|
||||
let body = format!(r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>{}{}</w:tbl><w:p/>"#, row(ex, "A"), row("", "B"));
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&out);
|
||||
assert_eq!(xml.matches("<w:tblPrEx>").count(), 1, "{xml}");
|
||||
assert!(xml.contains(r#"<w:tr><w:tblPrEx><w:tblW w:w="3000" w:type="dxa"/><w:jc w:val="center"/><w:tblInd w:w="240" w:type="dxa"/><w:tblLayout w:type="fixed"/></w:tblPrEx><w:trPr>"#), "{xml}");
|
||||
// Reading the output again gives the same exception on the first row only.
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
let x = t.rows[0].props.exceptions.as_ref().expect("first row keeps its exceptions");
|
||||
assert_eq!(x.width, Some(150.0));
|
||||
assert!(x.fixed);
|
||||
assert!(t.rows[1].props.exceptions.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_row_exception_element_is_kept() {
|
||||
let body = format!(r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>{}</w:tbl><w:p/>"#, row("<w:tblPrEx/>", "A"));
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(xml.contains("<w:tblPrEx></w:tblPrEx>") || xml.contains("<w:tblPrEx/>"), "{xml}");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Section form protection (`w:formProt` in `w:sectPr`, ECMA-376 Part 1 §17.6.6). Fixtures are
|
||||
//! hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_protection_round_trips_in_schema_order() {
|
||||
// Three sections: protected, explicitly unprotected, and one that says nothing.
|
||||
let body = concat!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:formProt/><w:titlePg/></w:sectPr></w:pPr><w:r><w:t>A</w:t></w:r></w:p>"#,
|
||||
r#"<w:p><w:pPr><w:sectPr><w:formProt w:val="0"/></w:sectPr></w:pPr><w:r><w:t>B</w:t></w:r></w:p>"#,
|
||||
r#"<w:p><w:r><w:t>C</w:t></w:r></w:p><w:sectPr/>"#
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
let flags: Vec<Option<bool>> = d.sections().iter().map(|(_, s)| s.form_prot).collect();
|
||||
assert_eq!(flags, vec![Some(true), Some(false), None]);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&out);
|
||||
// Before w:titlePg in CT_SectPr's sequence; a section that never said it writes none.
|
||||
assert!(xml.contains(r#"<w:formProt/><w:titlePg/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:formProt w:val="0"/>"#), "{xml}");
|
||||
assert_eq!(xml.matches("w:formProt").count(), 2);
|
||||
let again: Vec<Option<bool>> = wordcraft_docx::read(&out).unwrap().sections().iter().map(|(_, s)| s.form_prot).collect();
|
||||
assert_eq!(again, flags);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Section page-number settings (`w:pgNumType` in `w:sectPr`, ECMA-376 Part 1 §17.6.12).
|
||||
//! Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_type_is_kept_even_when_it_only_repeats_the_defaults() {
|
||||
// A decimal format with no start changes nothing, but the file said it, so saving says it too.
|
||||
let body = r#"<w:p><w:r><w:t>One</w:t></w:r></w:p><w:sectPr><w:pgNumType w:fmt="decimal"/></w:sectPr>"#;
|
||||
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(xml.contains(r#"<w:pgNumType w:fmt="decimal"/>"#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_type_with_a_start_and_format_still_round_trips() {
|
||||
let body = r#"<w:p><w:r><w:t>One</w:t></w:r></w:p><w:sectPr><w:pgNumType w:fmt="upperRoman" w:start="3"/></w:sectPr>"#;
|
||||
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert!(xml.contains(r#"w:fmt="upperRoman""#) && xml.contains(r#"w:start="3""#), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_without_page_number_type_writes_none() {
|
||||
let body = r#"<w:p><w:r><w:t>One</w:t></w:r></w:p><w:sectPr/>"#;
|
||||
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
assert!(!document_xml(&wordcraft_docx::write(&d).unwrap()).contains("pgNumType"));
|
||||
}
|
||||
@@ -48,3 +48,14 @@ fn section_text_direction_round_trips_in_schema_order() {
|
||||
let again: Vec<TextDirection> = back.sections().iter().map(|(_, s)| s.text_direction).collect();
|
||||
assert_eq!(again, dirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
|
||||
let body = r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:textDirection w:val="lrTb"/></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:textDirection w:val="lrTb"/></w:sectPr>"#;
|
||||
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert_eq!(xml.matches(r#"<w:textDirection w:val="lrTb"/>"#).count(), 2, "{xml}");
|
||||
// A horizontal section or cell that never said so still writes nothing.
|
||||
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
|
||||
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Section vertical alignment (`w:vAlign` in `w:sectPr`, ECMA-376 Part 1 §17.6.23, values from
|
||||
//! ST_VerticalJc). Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::section::PageVAlign;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// #1320: "both" (Justified) reads as Justify and writes back as "both"; the others keep theirs.
|
||||
#[test]
|
||||
fn section_vertical_alignment_round_trips_with_justified() {
|
||||
let sect = |v: &str| format!(r#"<w:sectPr><w:vAlign w:val="{v}"/></w:sectPr>"#);
|
||||
let body = format!(
|
||||
r#"<w:p><w:pPr>{}</w:pPr><w:r><w:t>A</w:t></w:r></w:p><w:p><w:pPr>{}</w:pPr><w:r><w:t>B</w:t></w:r></w:p><w:p><w:pPr>{}</w:pPr><w:r><w:t>C</w:t></w:r></w:p><w:p><w:pPr>{}</w:pPr><w:r><w:t>D</w:t></w:r></w:p><w:p><w:r><w:t>E</w:t></w:r></w:p>{}"#,
|
||||
sect("both"),
|
||||
sect("center"),
|
||||
sect("bottom"),
|
||||
sect("top"),
|
||||
sect("sideways")
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let got: Vec<PageVAlign> = d.sections().iter().map(|(_, s)| s.valign).collect();
|
||||
let want = vec![PageVAlign::Justify, PageVAlign::Center, PageVAlign::Bottom, PageVAlign::Top, PageVAlign::Top];
|
||||
assert_eq!(got, want);
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&out);
|
||||
assert_eq!(xml.matches(r#"<w:vAlign w:val="both"/>"#).count(), 1, "{xml}");
|
||||
assert_eq!(xml.matches(r#"<w:vAlign w:val="center"/>"#).count(), 1, "{xml}");
|
||||
assert_eq!(xml.matches(r#"<w:vAlign w:val="bottom"/>"#).count(), 1, "{xml}");
|
||||
assert_eq!(xml.matches("w:vAlign").count(), 3, "top writes nothing: {xml}");
|
||||
let back = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(back.sections().iter().map(|(_, s)| s.valign).collect::<Vec<_>>(), want);
|
||||
}
|
||||
@@ -213,3 +213,50 @@ fn hostile_word_xml_is_an_error_not_a_panic() {
|
||||
many.push_str("</pkg:package>");
|
||||
assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
/// A Flat OPC package of one paragraph with a picture, whose `pkg:binaryData` holds `data` as
|
||||
/// written, and `extra` after the parts.
|
||||
fn picture_package(data: &str, extra: &str) -> String {
|
||||
format!(
|
||||
r#"<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<pkg:part pkg:name="/_rels/.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml"><pkg:xmlData><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships></pkg:xmlData></pkg:part>
|
||||
<pkg:part pkg:name="/word/_rels/document.xml.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml"><pkg:xmlData><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/dot.png"/></Relationships></pkg:xmlData></pkg:part>
|
||||
<pkg:part pkg:name="/word/document.xml" pkg:contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"><pkg:xmlData><w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body><w:p><w:r><w:t>Seed</w:t></w:r><w:r><w:pict><v:shape style="width:24pt;height:12pt"><v:imagedata r:id="rId6"/></v:shape></w:pict></w:r></w:p></w:body></w:document></pkg:xmlData></pkg:part>
|
||||
<pkg:part pkg:name="/word/media/dot.png" pkg:contentType="image/png" pkg:compression="store"><pkg:binaryData>{data}</pkg:binaryData></pkg:part>{extra}
|
||||
</pkg:package>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A binary part's base64 reads the same as plain text, as CDATA or with character references;
|
||||
/// an entity in it is an error rather than bytes gone missing (#1353).
|
||||
#[test]
|
||||
fn flat_opc_binary_data_reads_the_same_in_cdata_and_character_references() {
|
||||
let png = tiny_png_b64();
|
||||
let media = |data: &str| read_xml(picture_package(data, "").as_bytes()).map(|d| d.media.values().cloned().collect::<Vec<_>>());
|
||||
let plain = media(&png).unwrap();
|
||||
assert_eq!(plain.len(), 1);
|
||||
let (head, tail) = png.split_at(png.len() / 2);
|
||||
let first = png.chars().next().unwrap();
|
||||
for other in
|
||||
[format!("<![CDATA[{png}]]>"), format!("{head}<![CDATA[{tail}]]>"), format!("&#{};{}", u32::from(first), &png[1..]).replace('\n', "
")]
|
||||
{
|
||||
assert_eq!(media(&other).unwrap(), plain, "{other}");
|
||||
}
|
||||
assert!(media(&format!("&{png}")).is_err());
|
||||
}
|
||||
|
||||
/// Work anywhere in a Flat OPC file is bounded, not only in the parts that are kept: nesting
|
||||
/// and elements no part reader looks at, and repeated part names, count too (#1353).
|
||||
#[test]
|
||||
fn flat_opc_limits_count_what_is_not_kept() {
|
||||
let png = tiny_png_b64();
|
||||
let limited = |extra: &str| matches!(read_xml(picture_package(&png, extra).as_bytes()), Err(DocxError::Limit(_)));
|
||||
assert!(read_xml(picture_package(&png, "<x/>").as_bytes()).is_ok());
|
||||
// Deep nesting outside the parts.
|
||||
assert!(limited(&format!("<x>{}{}</x>", "<y>".repeat(1000), "</y>".repeat(1000))));
|
||||
// One part name over and over, or other elements, past the part count.
|
||||
assert!(limited(&r#"<pkg:part pkg:name="/word/media/dot.png"><pkg:binaryData>AA==</pkg:binaryData></pkg:part>"#.repeat(20_000)));
|
||||
assert!(limited(&"<x/>".repeat(20_000)));
|
||||
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
|
||||
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
|
||||
}
|
||||
@@ -14,17 +14,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("caret.right", "Right", "Navigation", |s, v| lr(s, v, false)).key("Right").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.up", "Up", "Navigation", |s, v| vert(s, v, -1)).key("Up").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.down", "Down", "Navigation", |s, v| vert(s, v, 1)).key("Down").params(EXTEND).pure(),
|
||||
// In a right-to-left paragraph the word to the left is the next one.
|
||||
// In a right-to-left paragraph the word to the left is the next one. On macOS ⌘←/⌘→ go
|
||||
// to the line's start and end, as in Word for Mac and every Mac text field (#954).
|
||||
CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_right } else { word_left }))
|
||||
.key("Mod+Left / Alt+Left")
|
||||
.key(super::mac_or("Alt+Left", "Mod+Left / Alt+Left"))
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_left } else { word_right }))
|
||||
.key("Mod+Right / Alt+Right")
|
||||
.key(super::mac_or("Alt+Right", "Mod+Right / Alt+Right"))
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true))
|
||||
.key(super::mac_or("Home / Mod+Left", "Home"))
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false))
|
||||
.key(super::mac_or("End / Mod+Right", "End"))
|
||||
.params(EXTEND)
|
||||
.pure(),
|
||||
CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true)).key("Home").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false)).key("End").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.paraUp", "Paragraph Up", "Navigation", |s, v| mv(s, v, para_up)).key("Mod+Up / Alt+Up").params(EXTEND).pure(),
|
||||
CommandSpec::new("caret.paraDown", "Paragraph Down", "Navigation", |s, v| mv(s, v, para_down))
|
||||
.key("Mod+Down / Alt+Down")
|
||||
|
||||
@@ -328,7 +328,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(CmdError::Params("select a citation or give `entry`".into()));
|
||||
}
|
||||
let (_, b) = s.sel.ordered();
|
||||
s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, code: false, form: String::new() }, &CharProps { hidden: Some(true), ..Default::default() })?;
|
||||
s.doc.insert_object(&b, InlineObject::Field { instr: format!("TA \\l \"{}\"", entry.replace('"', "")), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }, &CharProps { hidden: Some(true), ..Default::default() })?;
|
||||
Ok(json!({"entry": entry}))
|
||||
})
|
||||
.params(r#"{"entry"?: string}"#)
|
||||
@@ -570,7 +570,7 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") };
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr, result: cite(&src, st.name, n, &pages), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&props,
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
@@ -693,7 +693,7 @@ pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Resul
|
||||
let end = head.len();
|
||||
head.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: instr.to_string(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)?;
|
||||
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
|
||||
@@ -897,8 +897,11 @@ fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
|
||||
ref_bookmark(s, &pos, show == "number", at)?
|
||||
};
|
||||
let instr = format!("{} {name} \\h", if show == "page" { "PAGEREF" } else { "REF" });
|
||||
let end =
|
||||
s.doc.insert_object(&at, InlineObject::Field { instr, result: result.clone(), locked: false, code: false, form: String::new() }, &props)?;
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
InlineObject::Field { instr, result: result.clone(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&props,
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(json!({"inserted": result}))
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key("Mod+Y / Mod+Shift+Z")
|
||||
.pure(),
|
||||
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(),
|
||||
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
|
||||
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V / Shift+Insert").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
|
||||
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
|
||||
.params(r#"{"text"?: string}"#)
|
||||
.key("Mod+Shift+Alt+V"),
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
read_opts(s, v);
|
||||
step(s, 1)
|
||||
})
|
||||
.key("Mod+G / F3 / Mod+PageDown")
|
||||
.key("Mod+G / F3 / Shift+F4 / Mod+PageDown")
|
||||
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, v| {
|
||||
@@ -125,7 +125,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.ui_requests.push(json!({"open": tab}));
|
||||
Ok(json!({"open": tab}))
|
||||
})
|
||||
.key("Mod+H")
|
||||
// Word for Mac's own key is ⌃H.
|
||||
.key(super::mac_or("Mod+H / Ctrl+H", "Mod+H"))
|
||||
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
@@ -140,7 +141,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.goal_x = None;
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Shift+F5")
|
||||
.key("Shift+F5 / Mod+Alt+Z")
|
||||
.pure(),
|
||||
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
|
||||
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
|
||||
@@ -165,12 +166,12 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
Ok(copy_result(s))
|
||||
}
|
||||
|
||||
/// What Copy and Cut return: the clipboard's text, and HTML with the equations as MathML when
|
||||
/// the copy holds any (#816), for the host to put on the system clipboard too.
|
||||
/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML,
|
||||
/// #816) for the host to put on the system clipboard too, so other programs keep the formatting.
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) if f.has_equation() => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
_ => String::new(),
|
||||
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
None => String::new(),
|
||||
};
|
||||
if s.clipboard_html.is_empty() { json!({"text": s.clipboard_text}) } else { json!({"text": s.clipboard_text, "html": s.clipboard_html}) }
|
||||
}
|
||||
|
||||
@@ -66,14 +66,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name without its extension)}"#).pure(),
|
||||
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
|
||||
CommandSpec::new("file.open", "Open", "File", open)
|
||||
.key("Mod+O / Mod+F12")
|
||||
.key("Mod+O / Mod+F12 / Mod+Alt+F2")
|
||||
.params(r#"{"path": string, "data"?: base64 (the file's bytes; `path` then only names it), "password"?: string (for a password-protected Word document)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save)
|
||||
.key("Mod+S / Shift+F12")
|
||||
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
|
||||
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
|
||||
// to undo; it marks the document as changed itself.
|
||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
|
||||
@@ -107,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.ui_requests.push(json!({"open": "print"}));
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+P / Mod+Shift+F12 / Mod+F2")
|
||||
.key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I")
|
||||
.pure(),
|
||||
CommandSpec::new("file.close", "Close", "File", |s, _| {
|
||||
s.ui_requests.push(json!({"close": true}));
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(),
|
||||
CommandSpec::new("document.text", "Document Text", "Agents", |s, v| {
|
||||
let story = super::story_param(s, v);
|
||||
Ok(json!({"text": s.doc.plain_text(story)}))
|
||||
Ok(json!({"text": s.doc.final_text(story)}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("document.paragraph", "Paragraph Details", "Agents", paragraph).params(r#"{"path": [n], "story"?: Story}"#).pure(),
|
||||
|
||||
@@ -18,9 +18,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
|
||||
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
|
||||
CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(),
|
||||
]
|
||||
}
|
||||
|
||||
fn load_system(s: &mut Session, _v: &Value) -> CmdResult {
|
||||
s.ui_requests.push(json!({"open": "loadSystemFonts"}));
|
||||
Ok(json!({"pending": true}))
|
||||
}
|
||||
|
||||
fn font_json(f: &UserFont) -> Value {
|
||||
json!({
|
||||
"file": f.file,
|
||||
|
||||
@@ -53,6 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.key("Mod+Shift+W"),
|
||||
CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on)))
|
||||
.key("Mod+Shift+X")
|
||||
.params(r#"{"value"?: bool}"#),
|
||||
// Note: off clears the direct property only; a border inherited from a character style stays on.
|
||||
CommandSpec::new("format.border", "Character Border", "Home › Font", |s, v| {
|
||||
@@ -800,7 +801,14 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(shape) => {
|
||||
let style = style.or(t.current.as_ref().map(|e| e.style)).unwrap_or_default();
|
||||
let e = Enclosure { shape, style, text: t.text.clone() };
|
||||
let obj = wordcraft_doc::InlineObject::Field { instr: e.instr(), result: t.text, locked: false, code: false, form: String::new() };
|
||||
let obj = wordcraft_doc::InlineObject::Field {
|
||||
instr: e.instr(),
|
||||
result: t.text,
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
s.doc.insert_object(&at, obj, &t.props)?
|
||||
}
|
||||
None => s.doc.insert_text(&at, &t.text, &t.props)?,
|
||||
|
||||
@@ -66,7 +66,7 @@ fn mark_entry(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let instr = xe_instr(s, v, &main)?;
|
||||
let hidden = CharProps { hidden: Some(true), ..Default::default() };
|
||||
let field = || InlineObject::Field { instr: instr.clone(), result: String::new(), locked: false, code: false, form: String::new() };
|
||||
let field = || InlineObject::Field { instr: instr.clone(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
if !p::bool(v, "all").unwrap_or(false) {
|
||||
let (_, b) = s.sel.ordered();
|
||||
s.doc.insert_object(&b, field(), &hidden)?;
|
||||
|
||||
@@ -222,22 +222,31 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
|
||||
}
|
||||
|
||||
/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it
|
||||
/// (TIFF included: the `image` crate decodes it).
|
||||
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
|
||||
match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
Some([b'I', b'I', 0x2A, 0x00]) | Some([b'M', b'M', 0x00, 0x2A]) => "tiff",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert an encoded image at the selection as an inline picture, `width` points wide (default:
|
||||
/// its natural size at 96 ppi), never wider than the text column.
|
||||
/// its natural size at 96 ppi), never wider than the text column. The caret ends after it, so a
|
||||
/// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The
|
||||
/// app selects a picture the person inserts (`WordApp::run`).
|
||||
fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str) -> CmdResult {
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
|
||||
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
_ => "png",
|
||||
};
|
||||
let ext = image_ext(&bytes);
|
||||
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
|
||||
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
|
||||
size
|
||||
@@ -265,7 +274,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(json!({"media": key, "width": w, "height": h}))
|
||||
}
|
||||
|
||||
@@ -345,7 +354,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text = p::str(v, "text").unwrap_or("").to_string();
|
||||
let w = p::f32(v, "width").unwrap_or(144.0).clamp(18.0, 2000.0);
|
||||
let h = p::f32(v, "height").unwrap_or(72.0).clamp(18.0, 2000.0);
|
||||
let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))]);
|
||||
let id = s.doc.try_add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))])?;
|
||||
let obj = InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
@@ -566,7 +575,7 @@ fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
|
||||
para.props.align = Some(Align::Center);
|
||||
para.insert_object(
|
||||
0,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)?;
|
||||
}
|
||||
@@ -731,7 +740,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => Align::Center,
|
||||
};
|
||||
let x_of_y = p::str(v, "format").is_some_and(|f| f.contains("of"));
|
||||
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() };
|
||||
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
if position == "current" {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
@@ -750,7 +759,7 @@ fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let end = para.len();
|
||||
para.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps { bold: Some(true), ..Default::default() },
|
||||
)?;
|
||||
} else {
|
||||
@@ -768,7 +777,7 @@ fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false, dirty: false, code: false, form: String::new() },
|
||||
&props,
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
@@ -876,7 +885,8 @@ fn field(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let result = p::str(v, "result").unwrap_or("").to_string();
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, code: false, form: String::new() }, &props)?;
|
||||
let end =
|
||||
s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false, dirty: false, code: false, form: String::new() }, &props)?;
|
||||
s.sel = Selection::caret(end);
|
||||
let _ = super::references::update_fields(s);
|
||||
sel_result(s)
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(out)
|
||||
})
|
||||
.params(r#"{"value"?: bool (show/hide the pane)}"#)
|
||||
.key("Shift+F1")
|
||||
.pure(),
|
||||
CommandSpec::new("styles.inspect", "Inspect Styles at Selection", "Home › Styles › Style Inspector", |s, _| Ok(inspect(s))).pure(),
|
||||
CommandSpec::new("styles.inspectorClear", "Clear Formatting Level", "Home › Styles › Style Inspector", clear)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, ListKind, Num, levels_for};
|
||||
use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for};
|
||||
use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor};
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
|
||||
@@ -193,13 +193,7 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
|
||||
touch_lists(s, &sharing)?;
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let aid = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
|
||||
s.doc.numbering.abstracts.push(AbstractNum { id: aid, name, levels: levels.clone(), ..Default::default() });
|
||||
let nid = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
s.doc.numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
nid
|
||||
}
|
||||
None => s.doc.numbering.try_add_levels(name, levels.clone())?,
|
||||
};
|
||||
// Levels linked to a paragraph style number every paragraph in that style.
|
||||
let mut linked = Vec::new();
|
||||
|
||||
@@ -475,7 +475,14 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
InlineObject::Field { instr: instr.to_string(), result: placeholder.to_string(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field {
|
||||
instr: instr.to_string(),
|
||||
result: placeholder.to_string(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&props,
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
@@ -1382,7 +1389,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if !content.is_empty() {
|
||||
made += 1;
|
||||
}
|
||||
cell.props.valign = wordcraft_doc::props::VAlign::Center;
|
||||
cell.props.valign = Some(wordcraft_doc::props::VAlign::Center);
|
||||
cell.blocks = content
|
||||
.split('\n')
|
||||
.take(20)
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod smart_art;
|
||||
pub mod speech;
|
||||
pub mod table;
|
||||
pub mod table_draw;
|
||||
pub mod table_formula;
|
||||
pub mod table_mouse;
|
||||
pub mod table_resize;
|
||||
pub mod table_style;
|
||||
|
||||
@@ -220,7 +220,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
|
||||
let key = s.doc.add_media(bytes, "png");
|
||||
let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
*media = key.clone();
|
||||
@@ -1553,6 +1553,47 @@ mod tests {
|
||||
click_resize_and_drag("insert.picture", json!({"data": data, "width": 100}));
|
||||
}
|
||||
|
||||
/// #965: a picture or icon inserted by a command leaves the caret after it, so a script's next
|
||||
/// Enter or typing keeps it (and the saved file has it); Picture Format commands still reach it.
|
||||
#[test]
|
||||
fn typing_after_an_inserted_picture_keeps_it() {
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
for (id, params) in [("insert.picture", json!({"data": data, "width": 100})), ("insert.icon", json!({"id": "sun"}))] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run(id, ¶ms).unwrap();
|
||||
assert!(s.sel.is_collapsed(), "{id}: caret, not a selection");
|
||||
assert!(selected(&s).is_some(), "{id}: still the object Picture Format acts on");
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "cap"})).unwrap();
|
||||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1), "{id}");
|
||||
let paras: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => Some(p.text.replace(wordcraft_doc::para::OBJ, "#")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(paras, ["X", "#", "cap"], "{id}");
|
||||
let docx = crate::io::save_bytes("p.docx", &s.doc).unwrap();
|
||||
assert!(docx.windows(11).any(|w| w == b"word/media/"), "{id}: the saved file keeps the picture");
|
||||
}
|
||||
}
|
||||
|
||||
/// #965: two pictures inserted by commands in a row are both kept; the second doesn't
|
||||
/// replace the first.
|
||||
#[test]
|
||||
fn two_inserted_pictures_in_a_row_are_both_kept() {
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_pipeline() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -157,6 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.view.outline.first_line_only = p::bool(v, "value").unwrap_or(!s.view.outline.first_line_only);
|
||||
Ok(json!({"value": s.view.outline.first_line_only}))
|
||||
})
|
||||
.key(super::non_mac("Alt+Shift+L"))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
.when(in_outline)
|
||||
.pure(),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Layout tab: page setup (margins, orientation, size, columns), breaks, line numbers, hyphenation.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, Borders};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, MIN_GRID_PITCH, SectionProps, SectionStart,
|
||||
Columns, DocGrid, DocGridType, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, MAX_NOTE_START, MIN_GRID_PITCH, SectionProps, SectionStart,
|
||||
};
|
||||
use wordcraft_doc::{Block, Pos, StoryRef};
|
||||
|
||||
@@ -40,21 +41,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| {
|
||||
if let Some(props) = v.get("section") {
|
||||
let new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let mut new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
validate_section(&mut new)?;
|
||||
return with_sect(s, |x| *x = new.clone());
|
||||
}
|
||||
s.ui_requests.push(json!({"open": "pageSetup"}));
|
||||
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
|
||||
})
|
||||
.params(r#"{"section"?: SectionProps}"#),
|
||||
.params(r#"{"section"?: SectionProps} (replaces the whole section, headers and footers included; checked like Size, Margins, Columns, Line Numbers, Footnotes and Page Borders: non-finite numbers and margins that leave no room for text are refused; page sides 7.2–1584 pt, 1–12 columns with 0–300 pt gaps, header/footer/gutter 0–1584 pt, page border width 0.25–6 pt and distance 0–31 pt, note start 1–32767, line numbers start 1–32767, countBy 1–100, distance 0–1584 pt are clamped)"#),
|
||||
CommandSpec::new("layout.verticalAlign", "Vertical Alignment", "Layout › Page Setup › Layout", |s, v| {
|
||||
let va = match p::str(v, "value") {
|
||||
Some("center") => wordcraft_doc::props::VAlign::Center,
|
||||
Some("bottom") => wordcraft_doc::props::VAlign::Bottom,
|
||||
_ => wordcraft_doc::props::VAlign::Top,
|
||||
};
|
||||
let va = wordcraft_doc::section::PageVAlign::parse(p::str(v, "value").unwrap_or_default());
|
||||
with_sect(s, |x| x.valign = va)
|
||||
}),
|
||||
})
|
||||
.params(r#"{"value": "top|center|justify|bottom"} (also both|justified; anything else is top). Justified shares each page's spare room between its paragraphs and tables"#),
|
||||
CommandSpec::new("layout.sectionRtl", "Right-to-Left Section", "Layout › Page Setup", |s, v| {
|
||||
let on = p::bool(v, "value").unwrap_or(!sect(s).rtl);
|
||||
with_sect(s, |x| x.rtl = on)
|
||||
@@ -374,6 +373,90 @@ pub fn columns_preset_at(s: &Session) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a whole section handed to `layout.pageSetup` by the rules of Size, Margins, Columns,
|
||||
/// Line Numbers, Footnotes and Page Borders: a non-finite number or margins that leave no room
|
||||
/// for text is an error; the rest is clamped to the ranges those commands use.
|
||||
fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> {
|
||||
let bad = |what: &str| CmdError::Params(format!("section: {what} must be a finite number"));
|
||||
let finite = [x.page_w, x.page_h, x.margin_top, x.margin_bottom, x.margin_left, x.margin_right, x.header, x.footer, x.gutter, x.columns.space];
|
||||
if finite.iter().any(|n| !n.is_finite()) {
|
||||
return Err(bad("sizes, margins, distances and column gaps"));
|
||||
}
|
||||
if x.columns.widths.iter().any(|(w, sp)| !w.is_finite() || !sp.is_finite()) {
|
||||
return Err(bad("column widths"));
|
||||
}
|
||||
if x.line_numbers.as_ref().is_some_and(|l| !l.distance.is_finite()) {
|
||||
return Err(bad("the line number distance"));
|
||||
}
|
||||
if x.doc_grid.as_ref().is_some_and(|g| !g.line_pitch.is_finite()) {
|
||||
return Err(bad("the grid pitch"));
|
||||
}
|
||||
if x.page_borders.as_ref().is_some_and(|b| border_sides(b).any(|d| !d.width.is_finite() || !d.space.is_finite())) {
|
||||
return Err(bad("page border widths and distances"));
|
||||
}
|
||||
x.page_w = x.page_w.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE);
|
||||
x.page_h = x.page_h.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE);
|
||||
// Like Size, the orientation follows the sides.
|
||||
if x.page_w != x.page_h {
|
||||
x.landscape = x.page_w > x.page_h;
|
||||
}
|
||||
x.margin_top = x.margin_top.max(0.0);
|
||||
x.margin_bottom = x.margin_bottom.max(0.0);
|
||||
x.margin_left = x.margin_left.max(0.0);
|
||||
x.margin_right = x.margin_right.max(0.0);
|
||||
x.header = x.header.clamp(0.0, x.page_h);
|
||||
x.footer = x.footer.clamp(0.0, x.page_h);
|
||||
x.gutter = x.gutter.clamp(0.0, MAX_PAGE_SIDE);
|
||||
// A page narrower than the usual text room (the smallest is 0.1") needs only margins that
|
||||
// leave the whole of it: zero margins, gutter included.
|
||||
let room = |side: f32| MIN_TEXT_ROOM.min(side);
|
||||
if x.margin_left + x.margin_right + x.gutter > x.page_w - room(x.page_w) || x.margin_top + x.margin_bottom > x.page_h - room(x.page_h) {
|
||||
return Err(CmdError::Params("margins leave no room for text".into()));
|
||||
}
|
||||
x.columns.space = x.columns.space.clamp(0.0, 300.0);
|
||||
x.columns.widths.truncate(12);
|
||||
for (w, sp) in &mut x.columns.widths {
|
||||
*w = w.clamp(18.0, MAX_PAGE_SIDE);
|
||||
*sp = sp.clamp(0.0, 300.0);
|
||||
}
|
||||
// As in Columns: custom widths set the count; a single width is no custom layout.
|
||||
if x.columns.widths.len() > 1 {
|
||||
x.columns.count = x.columns.widths.len() as u32;
|
||||
} else {
|
||||
x.columns.widths.clear();
|
||||
x.columns.count = x.columns.count.clamp(1, 12);
|
||||
}
|
||||
if let Some(l) = &mut x.line_numbers {
|
||||
l.start = l.start.clamp(1, 32_767);
|
||||
l.count_by = l.count_by.clamp(1, 100);
|
||||
l.distance = l.distance.clamp(0.0, 1584.0);
|
||||
}
|
||||
if let Some(g) = &mut x.doc_grid {
|
||||
g.line_pitch = g.line_pitch.clamp(MIN_GRID_PITCH, MAX_GRID_PITCH);
|
||||
g.char_space = g.char_space.clamp(-MAX_CHAR_SPACE, MAX_CHAR_SPACE);
|
||||
}
|
||||
x.page_num_start = x.page_num_start.map(|n| n.min(100_000));
|
||||
for n in [&mut x.footnote_pr, &mut x.endnote_pr] {
|
||||
n.num_start = n.num_start.map(|v| v.clamp(1, MAX_NOTE_START));
|
||||
}
|
||||
// Page Borders: width 0.25–6 pt, distance from the text 0–31 pt.
|
||||
if let Some(b) = &mut x.page_borders {
|
||||
for d in border_sides_mut(b) {
|
||||
d.width = d.width.clamp(0.25, 6.0);
|
||||
d.space = d.space.clamp(0.0, 31.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn border_sides(b: &Borders) -> impl Iterator<Item = &Border> {
|
||||
[&b.top, &b.left, &b.bottom, &b.right, &b.between, &b.inside_v].into_iter().flatten()
|
||||
}
|
||||
|
||||
fn border_sides_mut(b: &mut Borders) -> impl Iterator<Item = &mut Border> {
|
||||
[&mut b.top, &mut b.left, &mut b.bottom, &mut b.right, &mut b.between, &mut b.inside_v].into_iter().flatten()
|
||||
}
|
||||
|
||||
fn margins(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let preset = p::str(v, "preset");
|
||||
let (t, b, l, r) = match preset {
|
||||
@@ -603,6 +686,144 @@ mod tests {
|
||||
Session::new(wordcraft_doc::Document::from_text("one\ntwo\nthree"))
|
||||
}
|
||||
|
||||
fn with_section(f: impl Fn(&mut Value)) -> Value {
|
||||
let mut v = serde_json::to_value(SectionProps::default()).unwrap();
|
||||
f(&mut v);
|
||||
json!({"section": v})
|
||||
}
|
||||
|
||||
/// `layout.pageSetup {section}` checks the values like the dedicated commands do.
|
||||
#[test]
|
||||
fn page_setup_section_is_validated() {
|
||||
let mut s = doc();
|
||||
let before = sect(&s);
|
||||
// Refused with an error, nothing changes: non-finite (overflowing), margins that leave no text room.
|
||||
for (what, p) in [
|
||||
("inf width", with_section(|v| v["pageW"] = json!(1e300))),
|
||||
("inf margin", with_section(|v| v["marginTop"] = json!(1e300))),
|
||||
("inf header", with_section(|v| v["header"] = json!(1e300))),
|
||||
("inf column space", with_section(|v| v["columns"]["space"] = json!(1e300))),
|
||||
("inf column width", with_section(|v| v["columns"]["widths"] = json!([[1e300, 0.0], [100.0, 0.0]]))),
|
||||
("inf line number distance", with_section(|v| v["lineNumbers"] = json!({"distance": 1e300}))),
|
||||
("inf grid pitch", with_section(|v| v["docGrid"] = json!({"type": "lines", "linePitch": 1e300, "charSpace": 0}))),
|
||||
("inf border space", with_section(|v| v["pageBorders"] = json!({"top": {"style": "single", "width": 1.0, "space": 1e300}}))),
|
||||
("inf border width", with_section(|v| v["pageBorders"] = json!({"top": {"style": "single", "width": 1e300, "space": 1.0}}))),
|
||||
("margins > page", with_section(|v| v["marginLeft"] = json!(5000.0))),
|
||||
("margins eat the height", with_section(|v| v["marginTop"] = json!(700.0))),
|
||||
("gutter eats the width", with_section(|v| v["gutter"] = json!(600.0))),
|
||||
("negative page", with_section(|v| v["pageW"] = json!(-5.0))),
|
||||
("zero page height", with_section(|v| v["pageH"] = json!(0.0))),
|
||||
] {
|
||||
assert!(s.run("layout.pageSetup", &p).is_err(), "{what}");
|
||||
assert_eq!(sect(&s), before, "{what}");
|
||||
}
|
||||
// Clamped.
|
||||
s.run(
|
||||
"layout.pageSetup",
|
||||
&with_section(|v| {
|
||||
v["pageW"] = json!(100.0);
|
||||
v["pageH"] = json!(100.0);
|
||||
v["marginTop"] = json!(-10.0);
|
||||
v["marginBottom"] = json!(0.0);
|
||||
v["marginLeft"] = json!(0.0);
|
||||
v["marginRight"] = json!(0.0);
|
||||
v["header"] = json!(-3.0);
|
||||
v["footer"] = json!(1e6);
|
||||
v["columns"]["count"] = json!(1000);
|
||||
v["columns"]["space"] = json!(-4.0);
|
||||
v["lineNumbers"] = json!({"countBy": 0, "start": 0, "distance": 1e6, "restart": "page"});
|
||||
v["docGrid"] = json!({"type": "lines", "linePitch": 1e9, "charSpace": 2_000_000_000});
|
||||
v["pageNumStart"] = json!(4_000_000_000u64);
|
||||
v["footnotePr"] = json!({"numStart": 4_000_000_000u64});
|
||||
v["endnotePr"] = json!({"numStart": 0});
|
||||
v["pageBorders"] =
|
||||
json!({"top": {"style": "single", "width": 1e6, "space": -5.0}, "left": {"style": "single", "width": 0.0, "space": 1e6}});
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_w, x.page_h), (100.0, 100.0));
|
||||
assert_eq!(x.margin_top, 0.0);
|
||||
assert_eq!(x.header, 0.0);
|
||||
assert_eq!(x.footer, x.page_h, "a footer distance stays on the page");
|
||||
assert_eq!(x.columns.count, 12);
|
||||
assert_eq!(x.columns.space, 0.0);
|
||||
let l = x.line_numbers.unwrap();
|
||||
assert_eq!((l.count_by, l.start, l.distance), (1, 1, 1584.0));
|
||||
let g = x.doc_grid.unwrap();
|
||||
assert_eq!((g.line_pitch, g.char_space), (wordcraft_doc::section::MAX_GRID_PITCH, wordcraft_doc::section::MAX_CHAR_SPACE));
|
||||
assert_eq!(x.page_num_start, Some(100_000));
|
||||
assert_eq!(x.footnote_pr.num_start, Some(wordcraft_doc::section::MAX_NOTE_START));
|
||||
assert_eq!(x.endnote_pr.num_start, Some(1));
|
||||
let b = x.page_borders.unwrap();
|
||||
let (t, lf) = (b.top.unwrap(), b.left.unwrap());
|
||||
assert_eq!((t.width, t.space), (6.0, 0.0));
|
||||
assert_eq!((lf.width, lf.space), (0.25, 31.0));
|
||||
// Huge pages clamp; columns and widths agree; orientation follows the sides.
|
||||
let mut s = doc();
|
||||
s.run(
|
||||
"layout.pageSetup",
|
||||
&with_section(|v| {
|
||||
v["pageW"] = json!(1e5);
|
||||
v["pageH"] = json!(792.0);
|
||||
v["landscape"] = json!(false);
|
||||
v["columns"]["count"] = json!(3);
|
||||
v["columns"]["widths"] = json!(vec![json!([1.0, 1e5]); 40]);
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!(x.page_w, 1584.0);
|
||||
assert!(x.landscape, "wider than tall is landscape");
|
||||
assert_eq!(x.columns.widths.len(), 12);
|
||||
assert_eq!(x.columns.count, 12, "the count follows the widths");
|
||||
assert!(x.columns.widths.iter().all(|(w, sp)| *w >= 18.0 && (0.0..=300.0).contains(sp)));
|
||||
// One width is no custom layout: equal columns.
|
||||
s.run("layout.pageSetup", &with_section(|v| v["columns"]["widths"] = json!([[200.0, 0.0]]))).unwrap();
|
||||
assert!(sect(&s).columns.widths.is_empty());
|
||||
}
|
||||
|
||||
/// A valid section still applies as given.
|
||||
#[test]
|
||||
fn page_setup_section_applies_a_valid_one() {
|
||||
let mut s = doc();
|
||||
s.run(
|
||||
"layout.pageSetup",
|
||||
&with_section(|v| {
|
||||
v["pageW"] = json!(595.0);
|
||||
v["pageH"] = json!(842.0);
|
||||
v["marginLeft"] = json!(50.0);
|
||||
v["columns"]["count"] = json!(2);
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let x = sect(&s);
|
||||
assert_eq!((x.page_w, x.page_h, x.margin_left, x.columns.count), (595.0, 842.0, 50.0, 2));
|
||||
}
|
||||
|
||||
/// A page smaller than the usual 36 pt of text room is fine with no margins; any margin or
|
||||
/// gutter on it is refused.
|
||||
#[test]
|
||||
fn page_setup_section_allows_a_tiny_page_without_margins() {
|
||||
let tiny = |m: f32, g: f32| {
|
||||
with_section(|v| {
|
||||
v["pageW"] = json!(7.2);
|
||||
v["pageH"] = json!(20.0);
|
||||
for k in ["marginTop", "marginBottom", "marginLeft", "marginRight"] {
|
||||
v[k] = json!(m);
|
||||
}
|
||||
v["gutter"] = json!(g);
|
||||
})
|
||||
};
|
||||
let mut s = doc();
|
||||
let before = sect(&s);
|
||||
assert!(s.run("layout.pageSetup", &tiny(1.0, 0.0)).is_err(), "a margin on a tiny page");
|
||||
assert!(s.run("layout.pageSetup", &tiny(0.0, 1.0)).is_err(), "a gutter on a tiny page");
|
||||
assert_eq!(sect(&s), before);
|
||||
s.run("layout.pageSetup", &tiny(0.0, 0.0)).unwrap();
|
||||
assert_eq!((sect(&s).page_w, sect(&s).page_h), (7.2, 20.0));
|
||||
}
|
||||
|
||||
/// Layout › Text Direction turns the section, or the table cell the caret is in; one undo step.
|
||||
#[test]
|
||||
fn text_direction_turns_the_section_or_the_cell() {
|
||||
|
||||
@@ -226,7 +226,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("styles.apply", "Apply Styles", "Home › Styles", apply_styles)
|
||||
.params(r#"{"style"?: string (name or id)}"#)
|
||||
.key("Mod+Shift+S")
|
||||
// On macOS ⌘⇧S is Save As, as in Word for Mac.
|
||||
.key(super::non_mac("Mod+Shift+S"))
|
||||
.pure(),
|
||||
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
@@ -536,8 +537,11 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
let num = match prev_num {
|
||||
Some(n) => n.num,
|
||||
None => match kind {
|
||||
ListKind::Bullet | ListKind::BulletChar(_) => s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind)),
|
||||
_ => s.doc.numbering.add_list(kind),
|
||||
ListKind::Bullet | ListKind::BulletChar(_) => match s.doc.numbering.find_kind(kind) {
|
||||
Some(n) => n,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
},
|
||||
_ => s.doc.numbering.try_add_list(kind)?,
|
||||
},
|
||||
};
|
||||
fmt(s, &|p| {
|
||||
@@ -615,7 +619,7 @@ fn tabs(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
let styles = s.doc.styles.clone();
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.5;
|
||||
let near = |a: f32, b: f32| (a - b).abs() < wordcraft_doc::props::TAB_POS_EPS;
|
||||
fmt(s, &|p| {
|
||||
// The stops the paragraph inherits from its style.
|
||||
let inherited = styles.resolve_para(&ParaProps { tabs: None, ..p.clone() }).tabs;
|
||||
|
||||
@@ -37,7 +37,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key("Mod+Alt+F")
|
||||
.params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#),
|
||||
CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote))
|
||||
.key("Mod+Alt+D")
|
||||
// macOS takes ⌘⌥D to show and hide the Dock; Word for Mac uses ⌘⌥E.
|
||||
.key(super::mac_or("Mod+Alt+E", "Mod+Alt+D"))
|
||||
.params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#),
|
||||
CommandSpec::new("references.convertNotes", "Convert Notes", "References › Footnotes", |s, v| {
|
||||
let (foot, end) = match p::req_str(v, "to")? {
|
||||
@@ -58,6 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
sel_result(s)
|
||||
})
|
||||
.key(super::non_mac("Alt+Shift+> / Alt+Shift+."))
|
||||
.pure(),
|
||||
CommandSpec::new("references.notes", "Show Notes", "References › Footnotes", |s, _| {
|
||||
let mut out = Vec::new();
|
||||
@@ -366,8 +368,29 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||||
let Some(after_end) = r.end.off.checked_add(OBJ.len_utf8()) else { return Ok(()) };
|
||||
let end = Pos { off: after_end, ..r.end.clone() };
|
||||
let props = s.doc.para_at(&r.start).map(|p| p.props_of_char(r.start.off).clone()).unwrap_or_default();
|
||||
let at = s.doc.delete_range(&r.start, &end)?;
|
||||
let field = InlineObject::Field { instr: r.instr.trim().to_string(), result: String::new(), locked: false, code: false, form: String::new() };
|
||||
let mut at = s.doc.delete_range(&r.start, &end)?;
|
||||
// A range that filled its own paragraphs right under a TOC heading (as Word and other writers
|
||||
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
|
||||
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
|
||||
let i = at.path.last();
|
||||
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"))
|
||||
{
|
||||
s.doc.remove_block(StoryRef::Body, &at.path)?;
|
||||
let heading = Path::top(i - 1);
|
||||
let len = s.doc.para(StoryRef::Body, &heading).map_or(0, |p| p.len());
|
||||
at = Pos { story: StoryRef::Body, path: heading, off: len };
|
||||
}
|
||||
let field = InlineObject::Field {
|
||||
instr: r.instr.trim().to_string(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
};
|
||||
let after = s.doc.insert_object(&at, field, &props)?;
|
||||
if s.doc.para_at(&after).is_some_and(|p| after.off < p.len()) {
|
||||
s.doc.split_paragraph(&after)?;
|
||||
@@ -417,7 +440,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading");
|
||||
head.insert_object(
|
||||
head.len(),
|
||||
InlineObject::Field { instr: toc_instr(v), result: String::new(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: toc_instr(v), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)?;
|
||||
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) {
|
||||
@@ -479,6 +502,19 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
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(()) };
|
||||
// A TOC field imported with an inline placeholder/cached result must have its result cleared so
|
||||
// that the engine's TOC shape matches `toc_span` and the DOCX writer spans the field across entries.
|
||||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name == "TOC" && !f.has('c') {
|
||||
result.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
p.touch();
|
||||
}
|
||||
let f = FieldCode::parse(&instr);
|
||||
let leader = leader.unwrap_or_else(|| {
|
||||
s.doc
|
||||
@@ -492,7 +528,10 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
let width = super::page::sect(s).text_width();
|
||||
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
|
||||
// at the end of the document, possibly in a wider (landscape) section.
|
||||
let section = s.doc.section_index_of(start);
|
||||
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
|
||||
let entries = toc_entries(s, &f);
|
||||
let links: Vec<Option<String>> = if f.has('h') {
|
||||
let mut v = Vec::new();
|
||||
@@ -529,7 +568,8 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1));
|
||||
para.mark = CharProps::default();
|
||||
if show_page && sep.contains('\t') {
|
||||
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader }]);
|
||||
let right = TabStop { pos: width - 0.5, align: TabAlign::Right, leader };
|
||||
para.props.tabs = Some(entry_tabs(s, ¶.props, &e.text, right));
|
||||
}
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?;
|
||||
}
|
||||
@@ -538,6 +578,25 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>) -> Result<(), CmdE
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tab stops of a TOC entry: the right-aligned stop for its page number, and, when the
|
||||
/// entry text itself has no tab, Clear stops for the left, centre and decimal stops its style
|
||||
/// sets before that. Those stops are for a number set off by a tab (`1.1<tab>Title`); with the
|
||||
/// 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();
|
||||
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 {
|
||||
if t.pos < right.pos - 0.5 && !matches!(t.align, TabAlign::Right | TabAlign::Bar) {
|
||||
tabs.push(TabStop { pos: t.pos, align: TabAlign::Clear, leader: TabLeader::None });
|
||||
}
|
||||
}
|
||||
}
|
||||
tabs.push(right);
|
||||
tabs
|
||||
}
|
||||
|
||||
fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
|
||||
let text = p::str(v, "text").unwrap_or("").to_string();
|
||||
// A custom mark: a few characters, as Word's dialog takes.
|
||||
@@ -547,7 +606,7 @@ fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
|
||||
NoteKind::Endnote => (PartKind::Endnote, "EndnoteText", "EndnoteReference"),
|
||||
};
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
let id = s.doc.add_part(pk, Vec::new());
|
||||
let id = s.doc.try_add_part(pk, Vec::new())?;
|
||||
para.insert_object(
|
||||
0,
|
||||
InlineObject::NoteRef { kind, id, custom: custom.clone() },
|
||||
@@ -701,7 +760,7 @@ fn caption(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let chapter = p::u64(v, "chapter").filter(|l| (1..=9).contains(l));
|
||||
let sep = p::str(v, "separator").and_then(|x| CHAPTER_SEPARATORS.iter().find(|s| **s == x)).copied().unwrap_or("-");
|
||||
let mut para = Paragraph::new().styled("Caption");
|
||||
let field = |instr: String| InlineObject::Field { instr, result: String::new(), locked: false, code: false, form: String::new() };
|
||||
let field = |instr: String| InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
if !p::bool(v, "excludeLabel").unwrap_or(false) {
|
||||
para.insert_text(0, &format!("{label} "), &CharProps::default())?;
|
||||
}
|
||||
@@ -781,8 +840,10 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) {
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
@@ -807,6 +868,20 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
let mut updates: Vec<(usize, String)> = Vec::new();
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
// A table formula reads the cells around its own cell again.
|
||||
if instr.trim_start().starts_with('=') {
|
||||
if let Some((tp, r, c)) = path.cell()
|
||||
&& let Some(t) = s.doc.table(StoryRef::Body, &tp)
|
||||
{
|
||||
let default_lang = super::default_lang(s);
|
||||
let text = match super::table_formula::evaluate_field_with(t, Some(&s.doc), default_lang.as_deref(), r, c, instr) {
|
||||
Ok(text) => text,
|
||||
Err(e) => format!("!{e}"),
|
||||
};
|
||||
updates.push((k, text));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let name = wordcraft_layout::fields::field_name(instr);
|
||||
match name.as_str() {
|
||||
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
|
||||
@@ -831,8 +906,10 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) {
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
@@ -841,6 +918,44 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
update_toc(s)
|
||||
}
|
||||
|
||||
/// Refresh every unlocked `DATE` and `TIME` field to now, as Word does whenever it shows,
|
||||
/// prints or exports a document: a letter written from a template carries today's date, not the
|
||||
/// date the template was last saved (#1672).
|
||||
///
|
||||
/// Only those two. `CREATEDATE`, `SAVEDATE` and `PRINTDATE` record when something happened and
|
||||
/// are not "now"; `REF`, `PAGEREF`, `TOC` and citations are refreshed on an explicit update
|
||||
/// (`fields.updateAll`) and not on open, again as Word does. A field carrying `w:fldLock` keeps
|
||||
/// its stored result, which is what the lock is for.
|
||||
pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(doc.parts.keys().copied().map(StoryRef::Part)).collect();
|
||||
for story in stories {
|
||||
for path in doc.para_paths(story) {
|
||||
let Some(p) = doc.para(story, &path) else { continue };
|
||||
let mut updates: Vec<(usize, String)> = Vec::new();
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let name = wordcraft_layout::fields::field_name(instr);
|
||||
if !matches!(name.as_str(), "DATE" | "TIME") {
|
||||
continue;
|
||||
}
|
||||
// Word's default picture when the code carries no `\@` switch.
|
||||
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
|
||||
updates.push((k, super::insert::format_date(&pic)));
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(para) = doc.para_mut(story, &path) else { continue };
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) {
|
||||
*result = r;
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A field's `\@` date picture: the quoted text after it (switches may follow), or one word.
|
||||
pub fn date_picture(instr: &str) -> Option<String> {
|
||||
let rest = instr.split_once("\\@")?.1.trim_start();
|
||||
|
||||
+143
-13
@@ -1,5 +1,7 @@
|
||||
//! Review tab: comments, track changes, word count, spelling (via the proofing hook).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::{Comment, Paragraph, PartKind, Pos, StoryRef, para_block};
|
||||
@@ -9,7 +11,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key("Mod+Alt+M").params(r#"{"text": string}"#),
|
||||
CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key(super::mac_or("Mod+Alt+A / Mod+Alt+M", "Mod+Alt+M")).params(r#"{"text": string}"#),
|
||||
CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#),
|
||||
CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment).params(r#"{"id": n, "text": string}"#),
|
||||
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#),
|
||||
@@ -52,7 +54,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.key(super::non_mac("Mod+Shift+G"))
|
||||
.pure(),
|
||||
CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(),
|
||||
CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue).key("F7 / Alt+F7").pure(),
|
||||
CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue)
|
||||
.key(super::mac_or("F7 / Alt+F7 / Mod+Alt+L", "F7 / Alt+F7"))
|
||||
.pure(),
|
||||
CommandSpec::new("review.issues", "Proofing Issues", "Review › Proofing", all_issues).pure(),
|
||||
CommandSpec::new("review.suggestions", "Spelling Suggestions", "Review › Proofing", suggestions).params(r#"{"pos"?: Pos}"#).pure(),
|
||||
CommandSpec::new("review.addToDictionary", "Add to Dictionary", "Review › Proofing", |s, v| {
|
||||
@@ -127,8 +131,8 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else {
|
||||
(a, b)
|
||||
};
|
||||
let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))]);
|
||||
let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0);
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
s.doc
|
||||
.comments
|
||||
@@ -146,8 +150,8 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params("no such comment".into()));
|
||||
}
|
||||
let text = p::req_str(v, "text")?;
|
||||
let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))]);
|
||||
let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0);
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
s.doc.comments.insert(
|
||||
id,
|
||||
@@ -156,6 +160,12 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// An id no comment has: one past the largest, or the lowest free one once a file used `u32::MAX`.
|
||||
fn comment_id(s: &Session) -> Result<u32, CmdError> {
|
||||
let ids = &s.doc.comments;
|
||||
wordcraft_doc::free_id(ids.keys().next_back().copied(), ids.keys().copied(), 0).ok_or_else(|| CmdError::Failed("no free comment id".into()))
|
||||
}
|
||||
|
||||
/// Replace a comment's (or reply's) text; each line becomes a paragraph.
|
||||
fn edit_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?;
|
||||
@@ -196,8 +206,11 @@ fn comment_at_caret(s: &Session) -> Option<u32> {
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_anchors(s: &mut Session, ids: &[u32]) -> Result<(), CmdError> {
|
||||
for story in [StoryRef::Body] {
|
||||
/// Removes the comments' anchors from every story: the body, headers, footers, notes and text
|
||||
/// boxes (#1345).
|
||||
fn remove_anchors(s: &mut Session, ids: &BTreeSet<u32>) -> Result<(), CmdError> {
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for story in stories {
|
||||
for path in s.doc.para_paths(story) {
|
||||
let offs: Vec<usize> = s
|
||||
.doc
|
||||
@@ -221,17 +234,37 @@ fn remove_anchors(s: &mut Session, ids: &[u32]) -> Result<(), CmdError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The comment and every reply below it, replies to replies included (#1345). A parent cycle
|
||||
/// from a hostile file ends the walk instead of looping.
|
||||
fn thread_of(s: &Session, id: u32) -> Vec<u32> {
|
||||
let mut children: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
|
||||
for (k, c) in &s.doc.comments {
|
||||
if let Some(parent) = c.parent {
|
||||
children.entry(parent).or_default().push(*k);
|
||||
}
|
||||
}
|
||||
let mut seen = BTreeSet::from([id]);
|
||||
let mut ids = vec![id];
|
||||
let mut i = 0;
|
||||
while let Some(&at) = ids.get(i) {
|
||||
for k in children.get(&at).into_iter().flatten() {
|
||||
if seen.insert(*k) {
|
||||
ids.push(*k);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let ids: Vec<u32> = if p::bool(v, "all").unwrap_or(false) {
|
||||
s.doc.comments.keys().copied().collect()
|
||||
} else {
|
||||
let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("no comment here".into()))?;
|
||||
// The comment and its replies.
|
||||
let mut v = vec![id];
|
||||
v.extend(s.doc.comments.iter().filter(|(_, c)| c.parent == Some(id)).map(|(k, _)| *k));
|
||||
v
|
||||
thread_of(s, id)
|
||||
};
|
||||
remove_anchors(s, &ids)?;
|
||||
remove_anchors(s, &ids.iter().copied().collect())?;
|
||||
for id in &ids {
|
||||
if let Some(c) = s.doc.comments.remove(id) {
|
||||
s.doc.parts.remove(&c.part);
|
||||
@@ -1351,6 +1384,103 @@ mod tests {
|
||||
assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Comment anchors left in any story (the body, headers, footers, notes and text boxes), with
|
||||
/// the story each sits in.
|
||||
fn anchors(s: &Session) -> Vec<(wordcraft_doc::StoryRef, u32)> {
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k)));
|
||||
let mut ids = Vec::new();
|
||||
for story in stories {
|
||||
for path in s.doc.para_paths(story) {
|
||||
let p = s.doc.para(story, &path).unwrap();
|
||||
for o in p.object_offsets() {
|
||||
if let Some(InlineObject::CommentStart { id } | InlineObject::CommentEnd { id }) = p.object_at(o) {
|
||||
ids.push((story, *id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// #1345: deleting a comment anchored in a header removes its anchors there, not just in the
|
||||
/// body; Undo brings the comment and its anchors back.
|
||||
#[test]
|
||||
fn deleting_a_header_comment_removes_its_anchors() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "Body text"})).unwrap();
|
||||
let hdr = s.run("insert.header", &json!({"text": "Header text"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let id = s.run("review.newComment", &json!({"text": "in the header"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
let in_header = vec![(wordcraft_doc::StoryRef::Part(hdr), id); 2];
|
||||
assert_eq!(anchors(&s), in_header, "both anchors sit in the header");
|
||||
s.run("review.deleteComment", &json!({"id": id})).unwrap();
|
||||
assert!(anchors(&s).is_empty(), "no anchor points at the deleted comment");
|
||||
assert!(s.doc.comments.is_empty());
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(anchors(&s), in_header);
|
||||
assert!(s.doc.comments.contains_key(&id));
|
||||
}
|
||||
|
||||
/// #1345: deleting a comment deletes the whole thread below it, replies to replies included,
|
||||
/// with their text parts; Undo restores all of them.
|
||||
#[test]
|
||||
fn deleting_a_comment_deletes_replies_to_replies() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "hello world"})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})).unwrap();
|
||||
let root = s.run("review.newComment", &json!({"text": "root"})).unwrap()["id"].as_u64().unwrap();
|
||||
let reply = s.run("review.reply", &json!({"id": root, "text": "reply"})).unwrap()["id"].as_u64().unwrap();
|
||||
let nested = s.run("review.reply", &json!({"id": reply, "text": "reply to reply"})).unwrap()["id"].as_u64().unwrap();
|
||||
let parts = |s: &Session| s.doc.comments.values().map(|c| c.part).collect::<Vec<_>>();
|
||||
let before = parts(&s);
|
||||
let deleted = s.run("review.deleteComment", &json!({"id": root})).unwrap();
|
||||
assert_eq!(deleted["deleted"], json!([root, reply, nested]));
|
||||
assert!(s.doc.comments.is_empty(), "no reply is left without its parent: {:?}", s.doc.comments);
|
||||
assert!(before.iter().all(|p| !s.doc.parts.contains_key(p)), "the comments' text parts are gone");
|
||||
assert!(anchors(&s).is_empty());
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.comments.keys().map(|k| u64::from(*k)).collect::<Vec<_>>(), vec![root, reply, nested]);
|
||||
assert_eq!(parts(&s), before);
|
||||
assert_eq!(anchors(&s).len(), 2);
|
||||
}
|
||||
|
||||
/// A parent cycle (possible in a hostile file) doesn't hang Delete; both comments go.
|
||||
#[test]
|
||||
fn deleting_a_comment_in_a_parent_cycle_ends() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let a = s.run("review.newComment", &json!({"text": "a"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
let b = s.run("review.reply", &json!({"id": a, "text": "b"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.doc.comments.get_mut(&a).unwrap().parent = Some(b);
|
||||
s.run("review.deleteComment", &json!({"id": a})).unwrap();
|
||||
assert!(s.doc.comments.is_empty());
|
||||
}
|
||||
|
||||
/// A very long reply chain (as a hostile file could hold) is deleted in one pass over the
|
||||
/// comments, not one pass per reply: 50,000 replies would otherwise freeze the app.
|
||||
#[test]
|
||||
fn deleting_a_long_reply_chain_stays_fast() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.run("document.setText", &json!({"text": "hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let root = s.run("review.newComment", &json!({"text": "root"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
let template = s.doc.comments.get(&root).unwrap().clone();
|
||||
for k in 1..=50_000u32 {
|
||||
s.doc.comments.insert(root + k, wordcraft_doc::Comment { parent: Some(root + k - 1), ..template.clone() });
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
s.run("review.deleteComment", &json!({"id": root})).unwrap();
|
||||
assert!(s.doc.comments.is_empty());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() {
|
||||
// Gap 19: an RTF file's tracked insertions and deletions open as tracked changes, also
|
||||
|
||||
@@ -166,7 +166,8 @@ mod tests {
|
||||
fn cite(d: &mut Document, block: usize, off: usize, text: &str) {
|
||||
let c = CharProps::default();
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(block)).unwrap();
|
||||
p.insert_object(off, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false }, &c).unwrap();
|
||||
p.insert_object(off, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c)
|
||||
.unwrap();
|
||||
let n = p.insert_text(off + 3, text, &c).unwrap();
|
||||
p.insert_object(off + 3 + n, InlineObject::FieldEnd, &c).unwrap();
|
||||
}
|
||||
@@ -180,7 +181,7 @@ mod tests {
|
||||
let c = CharProps::default();
|
||||
d.para_mut(StoryRef::Body, &Path::top(2))
|
||||
.unwrap()
|
||||
.insert_object(0, InlineObject::FieldStart { instr: "ADDIN ZOTERO_BIBL {} CSL_BIBLIOGRAPHY".into(), locked: false }, &c)
|
||||
.insert_object(0, InlineObject::FieldStart { instr: "ADDIN ZOTERO_BIBL {} CSL_BIBLIOGRAPHY".into(), locked: false, dirty: false }, &c)
|
||||
.unwrap();
|
||||
let p3 = d.para_mut(StoryRef::Body, &Path::top(3)).unwrap();
|
||||
let end = p3.len();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::locale_number::{format_number, parse_number};
|
||||
use wordcraft_doc::locale_number::parse_number;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))),
|
||||
};
|
||||
with_cells(s, |cl| {
|
||||
cl.props.valign = va;
|
||||
cl.props.valign = Some(va);
|
||||
for b in &mut cl.blocks {
|
||||
if let Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
p.props.align = Some(h);
|
||||
@@ -396,7 +396,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
cl.props.width = w;
|
||||
}
|
||||
if let Some(va) = valign {
|
||||
cl.props.valign = va;
|
||||
cl.props.valign = Some(va);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -745,7 +745,7 @@ fn borders(s: &mut Session, v: &Value) -> CmdResult {
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_text(c: &wordcraft_doc::Cell) -> String {
|
||||
pub(crate) fn cell_text(c: &wordcraft_doc::Cell) -> String {
|
||||
c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
@@ -815,55 +815,63 @@ fn to_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// `=SUM(ABOVE)`, `=SUM(LEFT)`, `=AVERAGE(ABOVE)`, `=COUNT(...)`, `=MAX`, `=MIN`, `=PRODUCT`.
|
||||
/// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again
|
||||
/// by Update Field. What a formula can use is described in [`super::table_formula`]. A formula
|
||||
/// that doesn't evaluate (an unknown function, a syntax error) is an error and inserts nothing.
|
||||
fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let f = p::str(v, "formula").unwrap_or("=SUM(ABOVE)").to_ascii_uppercase();
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let story = s.sel.focus.story;
|
||||
// Each cell's number is read in that cell's language (`3,50` in Polish is three and a half).
|
||||
let sources: Vec<&wordcraft_doc::Cell> = if f.contains("LEFT") {
|
||||
t.rows.get(r).map(|row| row.cells.iter().take(c).collect()).unwrap_or_default()
|
||||
} else {
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).collect()
|
||||
};
|
||||
let raw = p::str(v, "formula").unwrap_or("=SUM(ABOVE)");
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let default_lang = super::default_lang(s);
|
||||
let nums: Vec<f64> = sources
|
||||
.iter()
|
||||
.filter_map(|x| parse_number(cell_text(x).trim(), cell_lang(&s.doc, x).or_else(|| default_lang.clone()).as_deref()))
|
||||
.filter(|x| x.is_finite())
|
||||
.collect();
|
||||
// The result is written in the language of the formula's cell, else of the numbers it adds.
|
||||
let out_lang = s
|
||||
.doc
|
||||
.para(story, &s.sel.focus.path)
|
||||
.filter(|p| !p.plain_text().trim().is_empty())
|
||||
.and_then(|p| super::para_lang(&s.doc, p))
|
||||
.or_else(|| sources.iter().find_map(|x| cell_lang(&s.doc, x)))
|
||||
.or(default_lang);
|
||||
let val = if f.starts_with("=AVERAGE") {
|
||||
if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 }
|
||||
} else if f.starts_with("=COUNT") {
|
||||
nums.len() as f64
|
||||
} else if f.starts_with("=MAX") {
|
||||
nums.iter().copied().fold(f64::MIN, f64::max)
|
||||
} else if f.starts_with("=MIN") {
|
||||
nums.iter().copied().fold(f64::MAX, f64::min)
|
||||
} else if f.starts_with("=PRODUCT") {
|
||||
nums.iter().product()
|
||||
} else {
|
||||
nums.iter().sum()
|
||||
};
|
||||
let text = format_number(val, out_lang.as_deref());
|
||||
let instr = auto_formula_instr(t, r, c, raw, default_lang.as_deref());
|
||||
let text = super::table_formula::evaluate_field_with(t, Some(&s.doc), default_lang.as_deref(), r, c, &instr)?;
|
||||
let props = s.typing_props();
|
||||
let at = s.sel.focus.clone();
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false, code: false, form: String::new() },
|
||||
wordcraft_doc::InlineObject::Field {
|
||||
instr: instr.clone(),
|
||||
result: text.clone(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&props,
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(json!({"result": text}))
|
||||
Ok(json!({"result": text, "formula": instr}))
|
||||
}
|
||||
|
||||
/// A formula as a field stores it: upper case, with one leading `=`. A `\#` numeric picture is
|
||||
/// kept as typed.
|
||||
pub(crate) fn formula_instr(raw: &str) -> String {
|
||||
let body = raw.trim();
|
||||
let body = body.strip_prefix('=').unwrap_or(body);
|
||||
match super::table_formula::split_switch(body) {
|
||||
(formula, Some(switch)) => format!("={} {switch}", formula.trim().to_ascii_uppercase()),
|
||||
(formula, None) => format!("={}", formula.trim().to_ascii_uppercase()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare function name (`=SUM`, no parentheses) takes its direction from the cell: ABOVE when a
|
||||
/// cell above holds a number, else LEFT.
|
||||
fn auto_formula_instr(t: &Table, r: usize, c: usize, raw: &str, lang: Option<&str>) -> String {
|
||||
let instr = formula_instr(raw);
|
||||
let body = instr.strip_prefix('=').unwrap_or(&instr);
|
||||
if !body.contains('(') && matches!(body, "SUM" | "AVERAGE" | "COUNT" | "MAX" | "MIN" | "PRODUCT") {
|
||||
let g = t.grid_col(r, c);
|
||||
let has_above = (0..r).rev().any(|row| {
|
||||
t.cell_at_grid(row, g)
|
||||
.and_then(|ci| t.rows.get(row)?.cells.get(ci))
|
||||
.and_then(|cl| super::table_formula::cell_number_in(cl, lang))
|
||||
.is_some()
|
||||
});
|
||||
let dir = if has_above { "ABOVE" } else { "LEFT" };
|
||||
format!("={body}({dir})")
|
||||
} else {
|
||||
instr
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+A in a table, as in Word: the cell, then its row, then the table. A nested table that
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
//! Table formulas (Table Layout › Data › Formula): `=SUM(ABOVE)`, cell references and ranges
|
||||
//! (`=SUM(B2:B4)`, `=A1*2`), arithmetic, and the functions Word offers in tables. A formula field
|
||||
//! is re-evaluated by Update Field (F9), so its result follows the cells it reads.
|
||||
//!
|
||||
//! Cell references count rows from 1 at the top and columns from A at the left, by grid column.
|
||||
//! ABOVE, LEFT, RIGHT and BELOW sum the first run of numbers in that direction, as Word does: the
|
||||
//! cells next to the formula's cell are skipped while they hold no number (empty or text), then
|
||||
//! the numbers are summed up to the next cell that holds none. A function needs its arguments:
|
||||
//! `=SUM()` is a syntax error, as it is in Word.
|
||||
|
||||
use wordcraft_doc::table::{MAX_COLS, MAX_ROWS};
|
||||
use wordcraft_doc::{Cell, Table};
|
||||
|
||||
use crate::CmdError;
|
||||
|
||||
/// The longest formula accepted, in bytes.
|
||||
pub const MAX_FORMULA: usize = 512;
|
||||
/// The longest numeric picture (`\#` switch) used, in characters.
|
||||
const MAX_PICTURE: usize = 64;
|
||||
/// The deepest nesting of parentheses, function calls, unary signs and `^` chains.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
/// The most cells one evaluation reads; past it the formula is too complex to work out, so a
|
||||
/// hostile document full of huge ranges can't make Update Field hang.
|
||||
pub const MAX_CELL_VISITS: usize = 100_000;
|
||||
|
||||
/// Evaluate `formula` (with or without its leading `=`) for the cell in row `r`, cell index `c`.
|
||||
pub fn evaluate(t: &Table, r: usize, c: usize, formula: &str) -> Result<f64, CmdError> {
|
||||
evaluate_with(t, None, None, r, c, formula)
|
||||
}
|
||||
|
||||
pub fn evaluate_with(
|
||||
t: &Table,
|
||||
doc: Option<&wordcraft_doc::Document>,
|
||||
default_lang: Option<&str>,
|
||||
r: usize,
|
||||
c: usize,
|
||||
formula: &str,
|
||||
) -> Result<f64, CmdError> {
|
||||
if formula.len() > MAX_FORMULA {
|
||||
return Err(syntax(format!("the formula is longer than {MAX_FORMULA} characters")));
|
||||
}
|
||||
let body = formula.trim();
|
||||
let body = body.strip_prefix('=').unwrap_or(body);
|
||||
let mut parser = Parser { toks: tokenize(body)?, pos: 0, depth: 0 };
|
||||
let expr = parser.compare()?;
|
||||
if parser.pos != parser.toks.len() {
|
||||
return Err(syntax("unexpected text after the formula".into()));
|
||||
}
|
||||
let here = Here { t, doc, default_lang, r, c, g: t.grid_col(r, c), visits: std::cell::Cell::new(0) };
|
||||
match eval(&expr, &here)? {
|
||||
Val::Num(x) => finite(x),
|
||||
Val::List(_) => Err(syntax("a range or ABOVE/LEFT/RIGHT/BELOW needs a function, such as SUM".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a formula result shows: a whole number as is, otherwise two decimals.
|
||||
pub fn format_result(v: f64) -> String {
|
||||
wordcraft_doc::locale_number::format_number(v, None)
|
||||
}
|
||||
|
||||
/// The text a formula field shows: its result under its `\#` numeric picture when it has one (see
|
||||
/// [`format_picture`]), otherwise as [`format_result`] shows it.
|
||||
pub fn evaluate_field(t: &Table, r: usize, c: usize, instr: &str) -> Result<String, CmdError> {
|
||||
evaluate_field_with(t, None, None, r, c, instr)
|
||||
}
|
||||
|
||||
pub fn evaluate_field_with(
|
||||
t: &Table,
|
||||
doc: Option<&wordcraft_doc::Document>,
|
||||
default_lang: Option<&str>,
|
||||
r: usize,
|
||||
c: usize,
|
||||
instr: &str,
|
||||
) -> Result<String, CmdError> {
|
||||
let (formula, switch) = split_switch(instr);
|
||||
let value = evaluate_with(t, doc, default_lang, r, c, formula)?;
|
||||
Ok(match switch.and_then(picture_of) {
|
||||
Some(picture) => format_picture(value, &picture),
|
||||
None => {
|
||||
let out_lang = doc
|
||||
.and_then(|d| {
|
||||
t.rows.get(r).and_then(|row| row.cells.get(c)).and_then(|cl| {
|
||||
cl.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| super::para_lang(d, p))
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
doc.and_then(|d| {
|
||||
t.rows.iter().flat_map(|row| row.cells.iter()).take(100).find_map(|cl| {
|
||||
cl.blocks
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.find(|p| !p.plain_text().trim().is_empty())
|
||||
.and_then(|p| super::para_lang(d, p))
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| default_lang.map(str::to_string));
|
||||
wordcraft_doc::locale_number::format_number(value, out_lang.as_deref())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Splits a field's `\#` switch off its formula: `=SUM(ABOVE) \# "0.00"` gives the formula and the
|
||||
/// switch text (from the `\#` on).
|
||||
pub fn split_switch(instr: &str) -> (&str, Option<&str>) {
|
||||
match instr.find("\\#") {
|
||||
Some(at) => (instr[..at].trim_end(), Some(&instr[at..])),
|
||||
None => (instr, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture a `\#` switch names: a quoted one (`"#,##0.00"`) or a single word.
|
||||
fn picture_of(switch: &str) -> Option<String> {
|
||||
let rest = switch.strip_prefix("\\#")?.trim_start();
|
||||
let picture = match rest.strip_prefix('"') {
|
||||
Some(quoted) => quoted.split('"').next().unwrap_or_default(),
|
||||
None => rest.split_whitespace().next().unwrap_or_default(),
|
||||
};
|
||||
let picture: String = picture.chars().take(MAX_PICTURE).collect();
|
||||
(!picture.is_empty()).then_some(picture)
|
||||
}
|
||||
|
||||
/// The text of `value` under a numeric picture, as Word's `\#` pictures read: `0` is a digit that
|
||||
/// always shows, `#` one that shows when it has a value, `,` groups thousands, `.` marks the
|
||||
/// decimals, `%` multiplies by 100, and anything else shows as it is. Sections split by `;` give the
|
||||
/// positive, negative and zero forms; with one section a negative value takes a `-` in front.
|
||||
pub fn format_picture(value: f64, picture: &str) -> String {
|
||||
let sections: Vec<&str> = picture.split(';').collect();
|
||||
let (section, x, minus) = if value < 0.0 && sections.len() >= 2 {
|
||||
(sections[1], -value, false)
|
||||
} else if value == 0.0 && sections.len() >= 3 {
|
||||
(sections[2], 0.0, false)
|
||||
} else {
|
||||
(sections[0], value.abs(), value < 0.0)
|
||||
};
|
||||
let Some(start) = section.find(['#', '0']) else { return section.to_string() };
|
||||
let rest = §ion[start..];
|
||||
let len = rest.find(|c: char| !matches!(c, '#' | '0' | ',' | '.')).unwrap_or(rest.len());
|
||||
let (core, suffix) = rest.split_at(len);
|
||||
let prefix = §ion[..start];
|
||||
let x = if prefix.contains('%') || suffix.contains('%') { x * 100.0 } else { x };
|
||||
let (int_pic, frac_pic) = match core.split_once('.') {
|
||||
Some((int_pic, frac_pic)) => (int_pic, Some(frac_pic)),
|
||||
None => (core, None),
|
||||
};
|
||||
let min_int = int_pic.matches('0').count().min(MAX_PICTURE);
|
||||
let max_frac = frac_pic.map_or(0, |f| f.matches(['0', '#']).count()).min(15);
|
||||
let min_frac = frac_pic.map_or(0, |f| f.matches('0').count()).min(max_frac);
|
||||
let scale = 10f64.powi(max_frac as i32);
|
||||
let rounded = (x * scale).round() / scale;
|
||||
if !rounded.is_finite() {
|
||||
return format_result(value);
|
||||
}
|
||||
let digits = format!("{:.*}", max_frac, rounded);
|
||||
let (whole, decimals) = match digits.split_once('.') {
|
||||
Some((whole, decimals)) => (whole.to_string(), decimals.to_string()),
|
||||
None => (digits.clone(), String::new()),
|
||||
};
|
||||
let whole = if whole == "0" && min_int == 0 { String::new() } else { format!("{whole:0>min_int$}") };
|
||||
let whole = if int_pic.contains(',') { group_thousands(&whole) } else { whole };
|
||||
let mut decimals = decimals;
|
||||
while decimals.len() > min_frac && decimals.ends_with('0') {
|
||||
decimals.pop();
|
||||
}
|
||||
let mut body = whole;
|
||||
if !decimals.is_empty() {
|
||||
body.push('.');
|
||||
body.push_str(&decimals);
|
||||
}
|
||||
let sign = if minus && rounded != 0.0 { "-" } else { "" };
|
||||
format!("{sign}{prefix}{body}{suffix}")
|
||||
}
|
||||
|
||||
/// `1234567` → `1,234,567`.
|
||||
fn group_thousands(digits: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in digits.chars().enumerate() {
|
||||
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A cell's number as a formula or a sort key reads it: surrounding spaces, currency signs,
|
||||
/// thousands separators and `%` are ignored, and accounting negatives in parentheses count as
|
||||
/// negative. Anything else (letters included) makes the cell text, not a number.
|
||||
pub fn parse_number(text: &str, lang: Option<&str>) -> Option<f64> {
|
||||
let t = text.trim();
|
||||
let (t, negative) = match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
Some(inner) => (inner.trim(), true),
|
||||
None => (t, false),
|
||||
};
|
||||
if let Some(x) = wordcraft_doc::locale_number::parse_number(t, lang) {
|
||||
let x = if negative { -x.abs() } else { x };
|
||||
return x.is_finite().then_some(x);
|
||||
}
|
||||
let cleaned: String = t.chars().filter(|c| !matches!(c, ',' | '$' | '€' | '£' | '¥' | '৳' | '%') && !c.is_whitespace()).collect();
|
||||
if cleaned.is_empty() || !cleaned.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')) {
|
||||
return None;
|
||||
}
|
||||
let x = cleaned.parse::<f64>().ok()?;
|
||||
let x = if negative { -x } else { x };
|
||||
x.is_finite().then_some(x)
|
||||
}
|
||||
|
||||
/// The number in a cell, if its text is one.
|
||||
pub(crate) fn cell_number_in(c: &Cell, lang: Option<&str>) -> Option<f64> {
|
||||
parse_number(&super::table::cell_text(c), lang)
|
||||
}
|
||||
|
||||
fn syntax(msg: String) -> CmdError {
|
||||
CmdError::Failed(format!("syntax error: {msg}"))
|
||||
}
|
||||
|
||||
fn finite(x: f64) -> Result<f64, CmdError> {
|
||||
if x.is_finite() { Ok(x) } else { Err(CmdError::Failed("the result is not a number".into())) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Tok {
|
||||
Num(f64),
|
||||
/// A name: a function, a keyword or a cell reference (letters then digits), upper-cased.
|
||||
Name(String),
|
||||
Op(char),
|
||||
/// A comparison: `=`, `<>`, `<`, `<=`, `>` or `>=`.
|
||||
Cmp(&'static str),
|
||||
}
|
||||
|
||||
fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if ch.is_whitespace() {
|
||||
chars.next();
|
||||
} else if ch.is_ascii_digit() || ch == '.' {
|
||||
let mut lit = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_digit() || d == '.' {
|
||||
lit.push(d);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
|
||||
out.push(Tok::Num(x));
|
||||
} else if ch.is_ascii_alphabetic() {
|
||||
let mut name = String::new();
|
||||
while let Some(&d) = chars.peek() {
|
||||
if d.is_ascii_alphanumeric() {
|
||||
name.push(d.to_ascii_uppercase());
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(Tok::Name(name));
|
||||
} else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') {
|
||||
out.push(Tok::Op(ch));
|
||||
chars.next();
|
||||
} else if matches!(ch, '<' | '>' | '=') {
|
||||
chars.next();
|
||||
let next = chars.peek().copied();
|
||||
let (op, longer) = match (ch, next) {
|
||||
('<', Some('=')) => ("<=", true),
|
||||
('<', Some('>')) => ("<>", true),
|
||||
('>', Some('=')) => (">=", true),
|
||||
('<', _) => ("<", false),
|
||||
('>', _) => (">", false),
|
||||
_ => ("=", false),
|
||||
};
|
||||
if longer {
|
||||
chars.next();
|
||||
}
|
||||
out.push(Tok::Cmp(op));
|
||||
} else {
|
||||
return Err(syntax(format!("`{ch}` isn't part of a formula")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The keywords that name the cells next to the formula's cell.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Side {
|
||||
Above,
|
||||
Left,
|
||||
Right,
|
||||
Below,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Expr {
|
||||
Num(f64),
|
||||
/// A cell: (row, grid column).
|
||||
Cell(usize, usize, String),
|
||||
/// A rectangle of cells: (row0, grid col0, row1, grid col1), inclusive.
|
||||
Range(usize, usize, usize, usize),
|
||||
Side(Side),
|
||||
Neg(Box<Expr>),
|
||||
Bin(char, Box<Expr>, Box<Expr>),
|
||||
/// A comparison: 1 when it holds, 0 when it doesn't.
|
||||
Cmp(&'static str, Box<Expr>, Box<Expr>),
|
||||
Call(String, Vec<Expr>),
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
toks: Vec<Tok>,
|
||||
pos: usize,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn peek_op(&self, c: char) -> bool {
|
||||
matches!(self.toks.get(self.pos), Some(Tok::Op(x)) if *x == c)
|
||||
}
|
||||
|
||||
fn eat_op(&mut self, c: char) -> bool {
|
||||
let hit = self.peek_op(c);
|
||||
if hit {
|
||||
self.pos += 1;
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
fn enter(&mut self) -> Result<(), CmdError> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH { Err(syntax("the formula is nested too deeply".into())) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn leave(&mut self) {
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// The loosest level: one comparison of two sums (comparisons don't chain).
|
||||
fn compare(&mut self) -> Result<Expr, CmdError> {
|
||||
let lhs = self.expr()?;
|
||||
let Some(Tok::Cmp(op)) = self.toks.get(self.pos).cloned() else { return Ok(lhs) };
|
||||
self.pos += 1;
|
||||
let rhs = self.expr()?;
|
||||
Ok(Expr::Cmp(op, Box::new(lhs), Box::new(rhs)))
|
||||
}
|
||||
|
||||
fn expr(&mut self) -> Result<Expr, CmdError> {
|
||||
let mut lhs = self.term()?;
|
||||
loop {
|
||||
let op = if self.eat_op('+') {
|
||||
'+'
|
||||
} else if self.eat_op('-') {
|
||||
'-'
|
||||
} else {
|
||||
return Ok(lhs);
|
||||
};
|
||||
let rhs = self.term()?;
|
||||
lhs = Expr::Bin(op, Box::new(lhs), Box::new(rhs));
|
||||
}
|
||||
}
|
||||
|
||||
fn term(&mut self) -> Result<Expr, CmdError> {
|
||||
let mut lhs = self.power()?;
|
||||
loop {
|
||||
let op = if self.eat_op('*') {
|
||||
'*'
|
||||
} else if self.eat_op('/') {
|
||||
'/'
|
||||
} else {
|
||||
return Ok(lhs);
|
||||
};
|
||||
let rhs = self.power()?;
|
||||
lhs = Expr::Bin(op, Box::new(lhs), Box::new(rhs));
|
||||
}
|
||||
}
|
||||
|
||||
fn power(&mut self) -> Result<Expr, CmdError> {
|
||||
let base = self.unary()?;
|
||||
if self.eat_op('^') {
|
||||
// `^` is right-associative, so each one in a chain nests one level deeper.
|
||||
self.enter()?;
|
||||
let exp = self.power()?;
|
||||
self.leave();
|
||||
return Ok(Expr::Bin('^', Box::new(base), Box::new(exp)));
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
fn unary(&mut self) -> Result<Expr, CmdError> {
|
||||
if self.eat_op('-') {
|
||||
self.enter()?;
|
||||
let inner = self.unary()?;
|
||||
self.leave();
|
||||
return Ok(Expr::Neg(Box::new(inner)));
|
||||
}
|
||||
if self.eat_op('+') {
|
||||
self.enter()?;
|
||||
let inner = self.unary()?;
|
||||
self.leave();
|
||||
return Ok(inner);
|
||||
}
|
||||
self.primary()
|
||||
}
|
||||
|
||||
fn primary(&mut self) -> Result<Expr, CmdError> {
|
||||
let Some(tok) = self.toks.get(self.pos).cloned() else {
|
||||
return Err(syntax("the formula ends too soon".into()));
|
||||
};
|
||||
self.pos += 1;
|
||||
match tok {
|
||||
Tok::Num(x) => Ok(Expr::Num(x)),
|
||||
Tok::Op('(') => {
|
||||
self.enter()?;
|
||||
let inner = self.compare()?;
|
||||
if !self.eat_op(')') {
|
||||
return Err(syntax("a `(` is missing its `)`".into()));
|
||||
}
|
||||
self.leave();
|
||||
Ok(inner)
|
||||
}
|
||||
Tok::Name(name) => {
|
||||
if self.eat_op('(') {
|
||||
self.enter()?;
|
||||
let mut args = Vec::new();
|
||||
if !self.eat_op(')') {
|
||||
loop {
|
||||
args.push(self.compare()?);
|
||||
if self.eat_op(')') {
|
||||
break;
|
||||
}
|
||||
if !self.eat_op(',') {
|
||||
return Err(syntax(format!("`{name}` needs `,` or `)` between its arguments")));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.leave();
|
||||
return Ok(Expr::Call(name, args));
|
||||
}
|
||||
let side = match name.as_str() {
|
||||
"ABOVE" => Some(Side::Above),
|
||||
"LEFT" => Some(Side::Left),
|
||||
"RIGHT" => Some(Side::Right),
|
||||
"BELOW" => Some(Side::Below),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(side) = side {
|
||||
return Ok(Expr::Side(side));
|
||||
}
|
||||
// TRUE and FALSE need no parentheses.
|
||||
if matches!(name.as_str(), "TRUE" | "FALSE") {
|
||||
return Ok(Expr::Call(name, Vec::new()));
|
||||
}
|
||||
let (row, col) = cell_ref(&name).ok_or_else(|| syntax(format!("`{name}` isn't a cell reference or a function")))?;
|
||||
if self.eat_op(':') {
|
||||
let Some(Tok::Name(other)) = self.toks.get(self.pos).cloned() else {
|
||||
return Err(syntax("a range ends with a cell reference, such as B4".into()));
|
||||
};
|
||||
self.pos += 1;
|
||||
let (row2, col2) = cell_ref(&other).ok_or_else(|| syntax(format!("`{other}` isn't a cell reference")))?;
|
||||
return Ok(Expr::Range(row.min(row2), col.min(col2), row.max(row2), col.max(col2)));
|
||||
}
|
||||
Ok(Expr::Cell(row, col, name))
|
||||
}
|
||||
Tok::Op(c) => Err(syntax(format!("`{c}` is in the wrong place"))),
|
||||
Tok::Cmp(op) => Err(syntax(format!("`{op}` is in the wrong place"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `B4` → (row 3, column 1): the row counts from 1 and the column from A.
|
||||
fn cell_ref(name: &str) -> Option<(usize, usize)> {
|
||||
let letters: String = name.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
|
||||
let digits = name.get(letters.len()..)?;
|
||||
if letters.is_empty() || digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let col = letters.bytes().try_fold(0usize, |acc, b| acc.checked_mul(26)?.checked_add((b.to_ascii_uppercase() - b'A') as usize + 1))? - 1;
|
||||
let row = digits.parse::<usize>().ok()?.checked_sub(1)?;
|
||||
(col < MAX_COLS && row < MAX_ROWS).then_some((row, col))
|
||||
}
|
||||
|
||||
/// Where the formula's cell sits.
|
||||
struct Here<'a> {
|
||||
t: &'a Table,
|
||||
doc: Option<&'a wordcraft_doc::Document>,
|
||||
default_lang: Option<&'a str>,
|
||||
r: usize,
|
||||
c: usize,
|
||||
g: usize,
|
||||
/// Cells read so far in this evaluation (see [`MAX_CELL_VISITS`]).
|
||||
visits: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl Here<'_> {
|
||||
/// Count `n` more cells read, failing once the evaluation has read too many.
|
||||
fn visit(&self, n: usize) -> Result<(), CmdError> {
|
||||
let total = self.visits.get().saturating_add(n);
|
||||
self.visits.set(total);
|
||||
if total > MAX_CELL_VISITS { Err(CmdError::Failed("Formula too complex".into())) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn cell_lang(&self, c: &Cell) -> Option<String> {
|
||||
let para_lang = self.doc.and_then(|doc| {
|
||||
c.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| super::para_lang(doc, p))
|
||||
});
|
||||
para_lang.or_else(|| self.default_lang.map(str::to_string))
|
||||
}
|
||||
|
||||
fn cell_number(&self, c: &Cell) -> Option<f64> {
|
||||
let lang = self.cell_lang(c);
|
||||
let text = super::table::cell_text(c);
|
||||
parse_number(&text, lang.as_deref())
|
||||
}
|
||||
|
||||
/// The number in the cell covering grid column `g` of row `r`, counted against the budget.
|
||||
fn number_at(&self, r: usize, g: usize) -> Result<Option<f64>, CmdError> {
|
||||
self.visit(1)?;
|
||||
let ci = match self.t.cell_at_grid(r, g) {
|
||||
Some(ci) => ci,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let c = match self.t.rows.get(r).and_then(|row| row.cells.get(ci)) {
|
||||
Some(c) => c,
|
||||
None => return Ok(None),
|
||||
};
|
||||
Ok(self.cell_number(c))
|
||||
}
|
||||
}
|
||||
|
||||
enum Val {
|
||||
Num(f64),
|
||||
List(Vec<f64>),
|
||||
}
|
||||
|
||||
fn eval(e: &Expr, here: &Here) -> Result<Val, CmdError> {
|
||||
Ok(match e {
|
||||
Expr::Num(x) => Val::Num(*x),
|
||||
Expr::Cell(row, col, name) => {
|
||||
let x = here.number_at(*row, *col)?.ok_or_else(|| CmdError::Failed(format!("{name} doesn't hold a number")))?;
|
||||
Val::Num(x)
|
||||
}
|
||||
Expr::Range(r0, g0, r1, g1) => {
|
||||
// Only the part of the range inside the table holds cells.
|
||||
let mut v = Vec::new();
|
||||
let (r_end, g_end) = (here.t.rows.len().min(r1.saturating_add(1)), here.t.cols().min(g1.saturating_add(1)));
|
||||
for row in *r0..r_end {
|
||||
for g in *g0..g_end {
|
||||
v.extend(here.number_at(row, g)?);
|
||||
}
|
||||
}
|
||||
Val::List(v)
|
||||
}
|
||||
Expr::Side(side) => Val::List(side_numbers(here, *side)?),
|
||||
Expr::Neg(inner) => Val::Num(-scalar(eval(inner, here)?)?),
|
||||
Expr::Bin(op, a, b) => {
|
||||
let (x, y) = (scalar(eval(a, here)?)?, scalar(eval(b, here)?)?);
|
||||
Val::Num(match op {
|
||||
'+' => x + y,
|
||||
'-' => x - y,
|
||||
'*' => x * y,
|
||||
'/' => {
|
||||
if y == 0.0 {
|
||||
return Err(CmdError::Failed("division by zero".into()));
|
||||
}
|
||||
x / y
|
||||
}
|
||||
_ => {
|
||||
let p = x.powf(y);
|
||||
finite(p)?
|
||||
}
|
||||
})
|
||||
}
|
||||
Expr::Cmp(op, a, b) => {
|
||||
let (x, y) = (scalar(eval(a, here)?)?, scalar(eval(b, here)?)?);
|
||||
let holds = match *op {
|
||||
"=" => x == y,
|
||||
"<>" => x != y,
|
||||
"<" => x < y,
|
||||
"<=" => x <= y,
|
||||
">" => x > y,
|
||||
_ => x >= y,
|
||||
};
|
||||
Val::Num(truth(holds))
|
||||
}
|
||||
Expr::Call(name, args) => Val::Num(call(name, args, here)?),
|
||||
})
|
||||
}
|
||||
|
||||
/// A truth value as a formula reads it: 1 for true, 0 for false.
|
||||
fn truth(holds: bool) -> f64 {
|
||||
if holds { 1.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A value that must be one number (arithmetic and single-argument functions).
|
||||
fn scalar(v: Val) -> Result<f64, CmdError> {
|
||||
match v {
|
||||
Val::Num(x) => finite(x),
|
||||
Val::List(_) => Err(syntax("a range can only be used inside a function".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first run of numbers next to the formula's cell on one side (see the module note): the
|
||||
/// cells are skipped while they hold no number, then summed while they do.
|
||||
fn side_numbers(here: &Here, side: Side) -> Result<Vec<f64>, CmdError> {
|
||||
let mut out = Vec::new();
|
||||
let mut started = false;
|
||||
let mut take = |value: Option<f64>| -> bool {
|
||||
match value {
|
||||
Some(x) => {
|
||||
out.push(x);
|
||||
started = true;
|
||||
true
|
||||
}
|
||||
None => !started,
|
||||
}
|
||||
};
|
||||
match side {
|
||||
Side::Above => {
|
||||
for row in (0..here.r).rev() {
|
||||
if !take(here.number_at(row, here.g)?) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Side::Below => {
|
||||
for row in (here.r + 1)..here.t.rows.len() {
|
||||
if !take(here.number_at(row, here.g)?) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Side::Left | Side::Right => {
|
||||
let Some(row) = here.t.rows.get(here.r) else { return Ok(out) };
|
||||
here.visit(row.cells.len())?;
|
||||
let cells: Vec<Option<f64>> = row.cells.iter().map(|c| here.cell_number(c)).collect();
|
||||
let order: Vec<usize> = if side == Side::Left { (0..here.c).rev().collect() } else { ((here.c + 1)..cells.len()).collect() };
|
||||
for ci in order {
|
||||
if !take(cells.get(ci).copied().flatten()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Gather a function's arguments: numbers and lists (ranges and keywords) become one list.
|
||||
fn gather(args: &[Expr], here: &Here) -> Result<Vec<f64>, CmdError> {
|
||||
let mut all = Vec::new();
|
||||
for a in args {
|
||||
match eval(a, here)? {
|
||||
Val::Num(x) => all.push(finite(x)?),
|
||||
Val::List(v) => all.extend(v),
|
||||
}
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
/// The one number a single-argument function takes.
|
||||
fn one(name: &str, args: &[Expr], here: &Here) -> Result<f64, CmdError> {
|
||||
match args {
|
||||
[a] => scalar(eval(a, here)?),
|
||||
_ => Err(CmdError::Failed(format!("{name} takes one argument"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn call(name: &str, args: &[Expr], here: &Here) -> Result<f64, CmdError> {
|
||||
let needs_one = matches!(
|
||||
name,
|
||||
"SUM" | "COUNT" | "AVERAGE" | "MAX" | "MIN" | "PRODUCT" | "ABS" | "INT" | "SIGN" | "ROUND" | "MOD" | "AND" | "OR" | "NOT" | "IF" | "DEFINED"
|
||||
);
|
||||
if args.is_empty() && needs_one {
|
||||
return Err(syntax(format!("{name}() needs an argument")));
|
||||
}
|
||||
match name {
|
||||
"TRUE" | "FALSE" if args.is_empty() => Ok(truth(name == "TRUE")),
|
||||
"TRUE" | "FALSE" => Err(CmdError::Failed(format!("{name} takes no arguments"))),
|
||||
"NOT" => Ok(truth(one(name, args, here)? == 0.0)),
|
||||
"AND" | "OR" => {
|
||||
let v = gather(args, here)?;
|
||||
if v.is_empty() {
|
||||
return Err(CmdError::Failed(format!("{name} has no numbers to test")));
|
||||
}
|
||||
Ok(truth(if name == "AND" { v.iter().all(|x| *x != 0.0) } else { v.iter().any(|x| *x != 0.0) }))
|
||||
}
|
||||
// Only the branch taken is worked out, so an error in the other one doesn't count.
|
||||
"IF" => match args {
|
||||
[cond, yes, no] => {
|
||||
if scalar(eval(cond, here)?)? != 0.0 {
|
||||
scalar(eval(yes, here)?)
|
||||
} else {
|
||||
scalar(eval(no, here)?)
|
||||
}
|
||||
}
|
||||
_ => Err(CmdError::Failed("IF takes three arguments: a condition, the value when it is true, and the value when it is false".into())),
|
||||
},
|
||||
// DEFINED(cell) is 1 when the cell holds a value the formula can read, 0 when it doesn't.
|
||||
"DEFINED" => match args {
|
||||
[a] => Ok(truth(eval(a, here).is_ok())),
|
||||
_ => Err(CmdError::Failed("DEFINED takes one argument".into())),
|
||||
},
|
||||
"SUM" => Ok(gather(args, here)?.iter().sum()),
|
||||
"COUNT" => Ok(gather(args, here)?.len() as f64),
|
||||
"AVERAGE" => {
|
||||
let v = gather(args, here)?;
|
||||
Ok(if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 })
|
||||
}
|
||||
"MAX" => {
|
||||
let v = gather(args, here)?;
|
||||
Ok(if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) })
|
||||
}
|
||||
"MIN" => {
|
||||
let v = gather(args, here)?;
|
||||
Ok(if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::INFINITY, f64::min) })
|
||||
}
|
||||
"PRODUCT" => {
|
||||
let v = gather(args, here)?;
|
||||
Ok(if v.is_empty() { 1.0 } else { v.iter().product() })
|
||||
}
|
||||
"ABS" => Ok(one(name, args, here)?.abs()),
|
||||
"INT" => Ok(one(name, args, here)?.floor()),
|
||||
"SIGN" => Ok(one(name, args, here)?.signum()),
|
||||
"ROUND" => match args {
|
||||
[a, b] => {
|
||||
let x = scalar(eval(a, here)?)?;
|
||||
let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc();
|
||||
let scale = 10f64.powi(digits as i32);
|
||||
finite((x * scale).round() / scale)
|
||||
}
|
||||
_ => Err(CmdError::Failed("ROUND takes two arguments".into())),
|
||||
},
|
||||
"MOD" => match args {
|
||||
[a, b] => {
|
||||
let (x, y) = (scalar(eval(a, here)?)?, scalar(eval(b, here)?)?);
|
||||
if y == 0.0 {
|
||||
return Err(CmdError::Failed("division by zero".into()));
|
||||
}
|
||||
Ok(x - y * (x / y).floor())
|
||||
}
|
||||
_ => Err(CmdError::Failed("MOD takes two arguments".into())),
|
||||
},
|
||||
_ => Err(CmdError::Failed(format!("unknown formula function `{name}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn table(rows: &[&[&str]]) -> Table {
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(1);
|
||||
let mut t = Table::new(rows.len(), cols, 468.0);
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
for (c, text) in row.iter().enumerate() {
|
||||
if let Some(cl) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) {
|
||||
cl.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn ev(t: &Table, r: usize, c: usize, f: &str) -> Result<f64, CmdError> {
|
||||
evaluate(t, r, c, f)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_references_count_from_one_and_from_a() {
|
||||
assert_eq!(cell_ref("A1"), Some((0, 0)));
|
||||
assert_eq!(cell_ref("B4"), Some((3, 1)));
|
||||
assert_eq!(cell_ref("AA2"), Some((1, 26)));
|
||||
assert_eq!(cell_ref("A0"), None);
|
||||
assert_eq!(cell_ref("1A"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_of_numbers_above_is_summed_after_skipping_cells_without_numbers() {
|
||||
// Observed in Word: the cells above are skipped while empty or text, then the numbers are
|
||||
// summed up to the next cell that holds none.
|
||||
let t = table(&[&["5"], &["x"], &["2"], &[""]]);
|
||||
assert_eq!(ev(&t, 3, 0, "=SUM(ABOVE)"), Ok(2.0), "text stops the run; the 5 above it is not counted");
|
||||
assert_eq!(ev(&t, 2, 0, "=SUM(ABOVE)"), Ok(5.0), "text directly above is skipped, so the run is the 5 above it");
|
||||
let t = table(&[&["5"], &[""], &[""], &[""]]);
|
||||
assert_eq!(ev(&t, 3, 0, "=SUM(ABOVE)"), Ok(5.0), "empty cells are skipped up to the 5");
|
||||
let t = table(&[&["5"], &["3"], &[""], &["9"], &[""]]);
|
||||
assert_eq!(ev(&t, 4, 0, "=SUM(ABOVE)"), Ok(9.0), "the run is the 9 alone: the blank cuts it off");
|
||||
let t = table(&[&["x"], &["5"], &[""]]);
|
||||
assert_eq!(ev(&t, 2, 0, "=SUM(ABOVE)"), Ok(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_function_needs_its_arguments() {
|
||||
let t = table(&[&["1"], &[""]]);
|
||||
assert!(ev(&t, 1, 0, "=SUM()").is_err(), "empty parentheses are a syntax error, as in Word");
|
||||
assert!(ev(&t, 1, 0, "=SUM").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_functions_are_errors_not_sums() {
|
||||
let t = table(&[&["1"], &["4"], &["7"], &[""]]);
|
||||
let err = ev(&t, 3, 0, "=MEDIAN(ABOVE)").err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err.contains("MEDIAN"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_and_references_and_operators() {
|
||||
let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]);
|
||||
assert_eq!(ev(&t, 2, 0, "=SUM(A1:B2)"), Ok(10.0));
|
||||
assert_eq!(ev(&t, 2, 1, "=A1*B2+1"), Ok(5.0));
|
||||
assert_eq!(ev(&t, 2, 0, "=(A1+A2)/2"), Ok(2.0));
|
||||
assert_eq!(ev(&t, 2, 0, "=2^3-(-1)"), Ok(9.0));
|
||||
assert_eq!(ev(&t, 2, 0, "=AVERAGE(A1:B2)"), Ok(2.5));
|
||||
assert_eq!(ev(&t, 2, 0, "=MAX(A1:B2)"), Ok(4.0));
|
||||
assert_eq!(ev(&t, 2, 0, "=ROUND(2.5, 0)"), Ok(3.0));
|
||||
assert_eq!(ev(&t, 2, 0, "=MOD(7, 3)"), Ok(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn division_by_zero_and_bad_syntax_are_errors() {
|
||||
let t = table(&[&["1"], &["0"]]);
|
||||
assert!(ev(&t, 1, 0, "=A1/A2").is_err());
|
||||
assert!(ev(&t, 1, 0, "=SUM(A1").is_err());
|
||||
assert!(ev(&t, 1, 0, "=A1 A1").is_err());
|
||||
assert!(ev(&t, 1, 0, "=A1:").is_err());
|
||||
assert!(ev(&t, 1, 0, &format!("={}", "(".repeat(64))).is_err(), "deep nesting is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_read_currency_and_separators_but_not_letters() {
|
||||
assert_eq!(parse_number("$1,200", None), Some(1200.0));
|
||||
assert_eq!(parse_number(" 12% ", None), Some(12.0));
|
||||
assert_eq!(parse_number("(5)", None), Some(-5.0));
|
||||
assert_eq!(parse_number("x9", None), None);
|
||||
assert_eq!(parse_number("1a", None), None);
|
||||
assert_eq!(parse_number("inf", None), None);
|
||||
assert_eq!(parse_number("", None), None);
|
||||
assert_eq!(parse_number("3,50", Some("pl-PL")), Some(3.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparisons_give_one_or_zero_and_do_not_chain() {
|
||||
let t = table(&[&["5"], &["3"]]);
|
||||
assert_eq!(ev(&t, 1, 0, "=A1>A2"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=A1<=A2"), Ok(0.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=A1<>A2"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=A2>=3"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=A1=5"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=A1<A2<1"), Err(CmdError::Failed("syntax error: unexpected text after the formula".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_and_or_not_and_the_truth_values() {
|
||||
let t = table(&[&["5", "x"], &["3", ""]]);
|
||||
assert_eq!(ev(&t, 1, 0, "=IF(A1>A2, 10, 20)"), Ok(10.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=IF(A1<A2, 10, 20)"), Ok(20.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=IF(A1>A2, 1, B1+1)"), Ok(1.0), "the branch not taken is not worked out");
|
||||
assert!(ev(&t, 1, 0, "=IF(A1<A2, 1, B1+1)").is_err(), "the branch taken is");
|
||||
assert_eq!(ev(&t, 1, 0, "=AND(A1>1, A2>1)"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=AND(A1>1, A2>4)"), Ok(0.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=OR(A1<1, A2>1)"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=OR(A1<1, A2<1)"), Ok(0.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=NOT(A1<1)"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=TRUE"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 1, 0, "=FALSE()"), Ok(0.0));
|
||||
assert!(ev(&t, 1, 0, "=TRUE(1)").is_err());
|
||||
assert!(ev(&t, 1, 0, "=IF(A1>A2, 1)").is_err(), "IF takes its three arguments");
|
||||
assert!(ev(&t, 1, 0, "=AND()").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defined_says_whether_a_cell_holds_a_value() {
|
||||
let t = table(&[&["5", "x", ""]]);
|
||||
assert_eq!(ev(&t, 0, 0, "=DEFINED(A1)"), Ok(1.0));
|
||||
assert_eq!(ev(&t, 0, 0, "=DEFINED(B1)"), Ok(0.0), "text isn't a value");
|
||||
assert_eq!(ev(&t, 0, 0, "=DEFINED(C1)"), Ok(0.0), "an empty cell isn't");
|
||||
assert_eq!(ev(&t, 0, 0, "=IF(DEFINED(C1), C1, 7)"), Ok(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_numeric_picture_formats_the_result() {
|
||||
assert_eq!(format_picture(1234.5, "#,##0.00"), "1,234.50");
|
||||
assert_eq!(format_picture(0.256, "0%"), "26%");
|
||||
assert_eq!(format_picture(2.0, "#.##"), "2");
|
||||
assert_eq!(format_picture(0.05, "0.0"), "0.1", "halves round away from zero");
|
||||
assert_eq!(format_picture(-5.0, "$#,##0"), "-$5", "one section: a minus in front");
|
||||
assert_eq!(format_picture(-5.0, "$#,##0.00;($#,##0.00)"), "($5.00)", "the second section is the negative form");
|
||||
assert_eq!(format_picture(0.0, "0.00;-;zero"), "zero");
|
||||
assert_eq!(format_picture(7.0, "000"), "007");
|
||||
assert_eq!(format_picture(-0.001, "0.00"), "0.00", "a rounded zero has no sign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_switch_picks_the_picture_and_the_formula_stays_apart_from_it() {
|
||||
let t = table(&[&["1234.5"], &[""]]);
|
||||
assert_eq!(split_switch("=A1 \\# \"0.00\""), ("=A1", Some("\\# \"0.00\"")));
|
||||
assert_eq!(split_switch("=A1"), ("=A1", None));
|
||||
let shown = |f: &str| evaluate_field(&t, 1, 0, f).unwrap_or_else(|e| panic!("{f}: {e}"));
|
||||
assert_eq!(shown("=A1 \\# \"#,##0.00\""), "1,234.50");
|
||||
assert_eq!(shown("=A1"), "1234.50");
|
||||
assert_eq!(shown("=A1 \\# 0"), "1235", "a bare picture word");
|
||||
assert_eq!(shown("=A1 \\# \"\""), "1234.50", "an empty picture is none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_range_over_a_small_table_reads_only_the_table() {
|
||||
let t = table(&[&["1", "2", "3"], &["4", "5", "6"], &["", "", ""]]);
|
||||
// Forty ranges reaching to the last possible row and column would be about 80 million
|
||||
// grid positions; clamped to the table they are 6 numbers each, well inside the budget.
|
||||
let f = format!("=SUM({})", ["A1:BK32767"; 40].join(","));
|
||||
assert!(f.len() <= MAX_FORMULA);
|
||||
assert_eq!(ev(&t, 2, 0, &f).unwrap(), 21.0 * 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_evaluation_past_the_cell_budget_is_an_error() {
|
||||
let mut t = Table::new(1000, 10, 468.0);
|
||||
t.rows[0].cells[0] = Cell::with_text("1");
|
||||
// Each range reads 10,000 cells, so eleven of them go past the 100,000-cell budget.
|
||||
let ok = format!("=SUM({})", ["A1:J1000"; 10].join(","));
|
||||
assert_eq!(ev(&t, 0, 1, &ok).unwrap(), 10.0);
|
||||
let too_much = format!("=SUM({})", ["A1:J1000"; 11].join(","));
|
||||
let err = ev(&t, 0, 1, &too_much).unwrap_err();
|
||||
assert_eq!(err, CmdError::Failed("Formula too complex".into()));
|
||||
assert_eq!(evaluate_field(&t, 0, 1, &too_much).unwrap_err().to_string(), "Formula too complex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_power_and_sign_chains_are_rejected() {
|
||||
let t = table(&[&["1"]]);
|
||||
assert_eq!(ev(&t, 0, 0, "=2^3^2").unwrap(), 512.0);
|
||||
assert_eq!(ev(&t, 0, 0, "=-+-+2").unwrap(), 2.0);
|
||||
for f in [format!("={}1", "1^".repeat(40)), format!("={}1", "+".repeat(40)), format!("={}1", "-+".repeat(20))] {
|
||||
let err = ev(&t, 0, 0, &f).unwrap_err();
|
||||
assert!(err.to_string().contains("nested too deeply"), "{f}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ pub fn cell_rects(l: &DocLayout, doc: &Document, range: &CellRange) -> Vec<(usiz
|
||||
for it in &pg.items {
|
||||
// A vertically merged cell's box spans its rows (#640); its continuation rows'
|
||||
// `covered` boxes would shade it twice.
|
||||
if let Placed::Cell { rect, table, row, cell, story, covered: false } = it
|
||||
if let Placed::Cell { rect, table, row, cell, story, covered: false, .. } = it
|
||||
&& *story == range.story
|
||||
&& *table == range.table
|
||||
&& picked.contains(&(*row, *cell))
|
||||
|
||||
@@ -210,30 +210,20 @@ impl ResizeTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// When the selection runs across cells of this table (Word selects them whole) and the
|
||||
/// column border grabbed at page y `y` is one of theirs, in one of their rows, the drag
|
||||
/// moves it only in those rows (`only`), as Word does with selected cells' borders. Not the
|
||||
/// table's left edge, which moves the whole table, nor a border of a cell merged down into
|
||||
/// rows outside the selection. Text selected within one cell isn't a cell selection.
|
||||
/// When cells of this table are selected whole (one cell too) or the selection runs across
|
||||
/// cells of it (Word selects them whole), and the column border grabbed at page y `y` is one
|
||||
/// of theirs, in one of their rows, the drag moves it only in those rows (`only`), as Word
|
||||
/// does with selected cells' borders. Not the table's left edge, which moves the whole table,
|
||||
/// nor a border of a cell merged down into rows outside the selection. Text selected within
|
||||
/// one cell isn't a cell selection.
|
||||
pub fn limit_to_selection(&mut self, s: &Session, y: f32) {
|
||||
self.only.clear();
|
||||
let Edge::Column(line) = self.edge else { return };
|
||||
let (a, b) = s.sel.ordered();
|
||||
if line == 0 || s.sel.is_collapsed() || a.story != self.story || b.story != self.story {
|
||||
if line == 0 {
|
||||
return;
|
||||
}
|
||||
let (Some(t), Some((ra, ca)), Some((rb, cb))) =
|
||||
(s.doc.table(self.story, &self.table), cell_in(&self.table, &a.path), cell_in(&self.table, &b.path))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if (ra, ca) == (rb, cb) {
|
||||
return;
|
||||
}
|
||||
let span = |r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span);
|
||||
let (ga, gb) = (t.grid_col(ra, ca), t.grid_col(rb, cb));
|
||||
let (lo, hi) = (ga.min(gb), (ga + span(ra, ca)).max(gb + span(rb, cb)));
|
||||
let (r0, r1) = (ra.min(rb), ra.max(rb));
|
||||
let Some(t) = s.doc.table(self.story, &self.table) else { return };
|
||||
let Some(((r0, r1), (lo, hi))) = self.selected_block(s, t) else { return };
|
||||
let here = self.rows.iter().find(|r| y >= r.top && y <= r.bottom).map(|r| r.row);
|
||||
if line < lo || line > hi || !here.is_some_and(|r| (r0..=r1).contains(&r)) {
|
||||
return;
|
||||
@@ -258,6 +248,30 @@ impl ResizeTarget {
|
||||
}
|
||||
self.only = only;
|
||||
}
|
||||
|
||||
/// The selected cells of this table, as their rows (first, last) and the grid lines of
|
||||
/// their outer borders (left, right): whole cells (`Session::cell_range`), or a selection
|
||||
/// running from one of its cells into another. `None` for text within one cell.
|
||||
fn selected_block(&self, s: &Session, t: &Table) -> Option<((usize, usize), (usize, usize))> {
|
||||
let lines = |cells: &[(usize, usize)]| -> Option<(usize, usize)> {
|
||||
let span = |r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span);
|
||||
let lo = cells.iter().map(|&(r, c)| t.grid_col(r, c)).min()?;
|
||||
let hi = cells.iter().map(|&(r, c)| t.grid_col(r, c).saturating_add(span(r, c))).max()?;
|
||||
Some((lo, hi))
|
||||
};
|
||||
if let Some(range) = s.cell_range().filter(|c| c.story == self.story && c.table == self.table) {
|
||||
return Some((range.rows, lines(&range.cells(t))?));
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
if s.sel.is_collapsed() || a.story != self.story || b.story != self.story {
|
||||
return None;
|
||||
}
|
||||
let ((ra, ca), (rb, cb)) = (cell_in(&self.table, &a.path)?, cell_in(&self.table, &b.path)?);
|
||||
if (ra, ca) == (rb, cb) {
|
||||
return None;
|
||||
}
|
||||
Some(((ra.min(rb), ra.max(rb)), lines(&[(ra, ca), (rb, cb)])?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the border right of column `i` of `w` (column widths, left to right) by up to `dx`
|
||||
@@ -964,6 +978,27 @@ mod tests {
|
||||
assert_eq!(first_table(&s), start, "one undo step each");
|
||||
}
|
||||
|
||||
/// One selected cell (Select Cell, unlike its text) is a cell selection too: its border
|
||||
/// moves in its row only (#962).
|
||||
#[test]
|
||||
fn a_selected_cells_border_moves_only_in_its_row() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (b, c) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
let y = b.y + b.h / 2.0;
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
|
||||
s.run("table.selectCell", &json!({})).unwrap();
|
||||
assert!(s.cell_range().is_some());
|
||||
let start = first_table(&s);
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
|
||||
let (b1, c1) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{b1:?} {c1:?}");
|
||||
for r in [0, 2] {
|
||||
assert!((cell_rect(&mut s, r, 1).right() - b.right()).abs() < 0.5, "row {r} keeps its border");
|
||||
}
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start, "one undo step");
|
||||
}
|
||||
|
||||
/// A cell merged down past the selection keeps one width: a border of it moves in the whole
|
||||
/// column.
|
||||
#[test]
|
||||
|
||||
@@ -5,25 +5,54 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, ParaProps, Rgb, TextColor, Underline, VAlign};
|
||||
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, TablePart, TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Table};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The regions a table style formats, by param name: the table style options they follow.
|
||||
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
|
||||
/// The parts a table style formats, by param name: the names the table style options follow
|
||||
/// (`headerRow`, `bandedRows`…) and the ECMA-376 §17.7.6 part names (`firstRow`, `band2Horz`…).
|
||||
pub const PARTS: [(&str, TablePart); 23] = [
|
||||
("wholeTable", TablePart::WholeTable),
|
||||
("headerRow", TablePart::FirstRow),
|
||||
("firstRow", TablePart::FirstRow),
|
||||
("lastRow", TablePart::LastRow),
|
||||
("totalRow", TablePart::LastRow),
|
||||
("firstColumn", TablePart::FirstCol),
|
||||
("firstCol", TablePart::FirstCol),
|
||||
("lastColumn", TablePart::LastCol),
|
||||
("lastCol", TablePart::LastCol),
|
||||
("bandedRows", TablePart::Band1Horz),
|
||||
("band1Horz", TablePart::Band1Horz),
|
||||
("band2Horz", TablePart::Band2Horz),
|
||||
("bandedColumns", TablePart::Band1Vert),
|
||||
("band1Vert", TablePart::Band1Vert),
|
||||
("band2Vert", TablePart::Band2Vert),
|
||||
("neCell", TablePart::NeCell),
|
||||
("nwCell", TablePart::NwCell),
|
||||
("seCell", TablePart::SeCell),
|
||||
("swCell", TablePart::SwCell),
|
||||
("topRightCell", TablePart::NeCell),
|
||||
("topLeftCell", TablePart::NwCell),
|
||||
("bottomRightCell", TablePart::SeCell),
|
||||
("bottomLeftCell", TablePart::SwCell),
|
||||
];
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
|
||||
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) takes the same
|
||||
/// shortcuts.
|
||||
/// The part a param name means.
|
||||
pub fn part_named(name: &str) -> Option<TablePart> {
|
||||
PARTS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, p)| *p)
|
||||
}
|
||||
|
||||
/// The formatting params both commands share; each part takes the same Part object.
|
||||
macro_rules! style_params {
|
||||
($head:literal) => {
|
||||
concat!(
|
||||
"{",
|
||||
$head,
|
||||
r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
|
||||
r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"#
|
||||
r#", "basedOn"?: string, "<part>"?: Part for each part name (wholeTable, headerRow|firstRow, lastRow|totalRow, firstColumn|firstCol, lastColumn|lastCol, bandedRows|band1Horz, band2Horz, bandedColumns|band1Vert, band2Vert, nwCell, neCell, swCell, seCell), "part"?: part name (the Part fields then sit at the top level), "parts"?: [Part & {"part": name}], "bandSize"?: n (rows per band), "para"?: ParaProps, "apply"?: bool}"#,
|
||||
r#" where Part = {"fill"?: "RRGGBB" | null, "font"?: string | null (null or "": inherited), "size"?: pt, "bold"?: bool, "italic"?: bool, "underline"?: bool | "single" | "double" | "thick" | "words" | "dotted" | "dash" | "wave" | "none", "color"?: "RRGGBB" | null, "align"?: "left" | "center" | "right" | "justify" | null, "vAlign"?: "top" | "center" | "bottom" | null, "cellAlign"?: "topLeft" | "topCenter" | "topRight" | "centerLeft" | "center" | "centerRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | null (both alignments), "borders"?: bool | null (true: single lines on every edge, false: none, null: inherit) | Edges | [Edges], "borderColor"?: "RRGGBB", "borderWidth"?: pt}"#,
|
||||
r#" and Edges = {"edges"?: "all" | "none" | "outside" | "inside" | "top" | "bottom" | "left" | "right" | "insideH" | "insideV" (default all), "style"?: "single" | "double" | "dotted" | "dashed" | "dotDash" | "thick" | "triple" | "wave" | "none", "width"?: pt, "color"?: "RRGGBB" | null}"#
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -254,30 +283,40 @@ fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> {
|
||||
|
||||
/// Apply the formatting params to `st`.
|
||||
fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
|
||||
let mut parts = st.table.take().unwrap_or_default();
|
||||
if let Some(r) = v.get("wholeTable") {
|
||||
region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?;
|
||||
for (key, part) in PARTS {
|
||||
if let Some(r) = v.get(key) {
|
||||
edit_part(st, part, r)?;
|
||||
}
|
||||
}
|
||||
if let Some(r) = v.get("headerRow") {
|
||||
region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?;
|
||||
match v.get("part") {
|
||||
None => {
|
||||
// Part fields at the top level only mean something with `part`; without it they'd be
|
||||
// silently dropped.
|
||||
if let Some(k) = PART_FIELDS.iter().find(|k| v.get(**k).is_some()) {
|
||||
return Err(CmdError::Params(format!("`{k}` is a part field: give `part`, or put it in a part (`wholeTable`, `headerRow`…)")));
|
||||
}
|
||||
}
|
||||
Some(Value::String(n)) => {
|
||||
let part = part_named(n).ok_or_else(|| CmdError::Params(format!("no table style part `{n}`")))?;
|
||||
edit_part(st, part, v)?;
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`part` is a part name".into())),
|
||||
}
|
||||
if let Some(r) = v.get("bandedRows") {
|
||||
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastRow") {
|
||||
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("firstColumn") {
|
||||
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastColumn") {
|
||||
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("bandedColumns") {
|
||||
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
|
||||
match v.get("parts") {
|
||||
None => {}
|
||||
Some(Value::Array(list)) => {
|
||||
for r in list.iter().take(64) {
|
||||
let n = p::str(r, "part").ok_or_else(|| CmdError::Params("each of `parts` names its `part`".into()))?;
|
||||
let part = part_named(n).ok_or_else(|| CmdError::Params(format!("no table style part `{n}`")))?;
|
||||
edit_part(st, part, r)?;
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`parts` is a list".into())),
|
||||
}
|
||||
if let Some(n) = v.get("bandSize") {
|
||||
let mut parts = st.table.take().unwrap_or_default();
|
||||
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
|
||||
st.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
}
|
||||
if let Some(pp) = v.get("para") {
|
||||
let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?;
|
||||
@@ -285,27 +324,136 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
|
||||
st.para.style = None;
|
||||
st.para.numbering = None;
|
||||
}
|
||||
st.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One region's shortcuts: borders, fill and character formatting.
|
||||
fn region(r: &Value, fill: &mut Option<Rgb>, chr: &mut CharProps, borders: &mut Option<Borders>) -> Result<(), CmdError> {
|
||||
if !r.is_object() {
|
||||
return Err(CmdError::Params("a region is an object".into()));
|
||||
/// The fields of a Part object.
|
||||
const PART_FIELDS: [&str; 13] =
|
||||
["fill", "font", "size", "bold", "italic", "underline", "color", "align", "vAlign", "cellAlign", "borders", "borderColor", "borderWidth"];
|
||||
|
||||
/// Apply one part's params `r` to part `part` of `st`.
|
||||
fn edit_part(st: &mut Style, part: TablePart, r: &Value) -> Result<(), CmdError> {
|
||||
let mut f = st.table_part(part);
|
||||
region(r, &mut f)?;
|
||||
st.set_table_part(part, f);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A colour param: `"RRGGBB"`, or null for none.
|
||||
fn color_param(r: &Value, k: &str) -> Result<Option<Rgb>, CmdError> {
|
||||
match r.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))),
|
||||
Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))),
|
||||
}
|
||||
let color = |k: &str| -> Result<Option<Rgb>, CmdError> {
|
||||
match r.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))),
|
||||
Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))),
|
||||
}
|
||||
|
||||
/// An explicit "no border" edge (`w:val="nil"`), which hides what a lower part draws.
|
||||
const NO_LINE: Border = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 };
|
||||
|
||||
/// The border styles by param name.
|
||||
const LINE_STYLES: [(&str, BorderStyle); 9] = [
|
||||
("none", BorderStyle::None),
|
||||
("single", BorderStyle::Single),
|
||||
("double", BorderStyle::Double),
|
||||
("dotted", BorderStyle::Dotted),
|
||||
("dashed", BorderStyle::Dashed),
|
||||
("dotDash", BorderStyle::DotDash),
|
||||
("thick", BorderStyle::Thick),
|
||||
("triple", BorderStyle::Triple),
|
||||
("wave", BorderStyle::Wave),
|
||||
];
|
||||
|
||||
/// Set `edges` of `b` to `line`, as the border picker does.
|
||||
pub fn set_edges(b: &mut Borders, edges: &str, line: Border) -> Result<(), CmdError> {
|
||||
let all = |b: &mut Borders, l: Border| *b = Borders::all(l);
|
||||
match edges {
|
||||
"all" => all(b, line),
|
||||
"none" => all(b, NO_LINE),
|
||||
"outside" | "box" => {
|
||||
(b.top, b.bottom, b.left, b.right) = (Some(line), Some(line), Some(line), Some(line));
|
||||
}
|
||||
"inside" => (b.between, b.inside_v) = (Some(line), Some(line)),
|
||||
"top" => b.top = Some(line),
|
||||
"bottom" => b.bottom = Some(line),
|
||||
"left" => b.left = Some(line),
|
||||
"right" => b.right = Some(line),
|
||||
"insideH" => b.between = Some(line),
|
||||
"insideV" => b.inside_v = Some(line),
|
||||
_ => return Err(CmdError::Params(format!("unknown border edges `{edges}`"))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One `Edges` object of the `borders` param.
|
||||
fn edges_param(e: &Value, b: &mut Borders) -> Result<(), CmdError> {
|
||||
if !e.is_object() {
|
||||
return Err(CmdError::Params("`borders` is true, false, null or {\"edges\", \"style\", \"width\", \"color\"}".into()));
|
||||
}
|
||||
let style = match e.get("style") {
|
||||
None => BorderStyle::Single,
|
||||
Some(Value::String(n)) => LINE_STYLES
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(n))
|
||||
.map(|(_, s)| *s)
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown border style `{n}`")))?,
|
||||
Some(_) => return Err(CmdError::Params("border `style` is a name".into())),
|
||||
};
|
||||
let width = p::f32(e, "width").unwrap_or(0.5).clamp(0.25, 6.0);
|
||||
let line = if style == BorderStyle::None { NO_LINE } else { Border { style, width, color: color_param(e, "color")?, space: 0.0 } };
|
||||
let edges = match e.get("edges") {
|
||||
None => "all",
|
||||
Some(Value::String(n)) => n.as_str(),
|
||||
Some(_) => return Err(CmdError::Params("border `edges` is a name".into())),
|
||||
};
|
||||
set_edges(b, edges, line)
|
||||
}
|
||||
|
||||
/// Horizontal alignment by param name.
|
||||
fn h_align(n: &str) -> Option<Align> {
|
||||
match n {
|
||||
"left" | "start" => Some(Align::Left),
|
||||
"center" => Some(Align::Center),
|
||||
"right" | "end" => Some(Align::Right),
|
||||
"justify" | "both" => Some(Align::Justify),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment by param name.
|
||||
fn v_align(n: &str) -> Option<VAlign> {
|
||||
match n {
|
||||
"top" => Some(VAlign::Top),
|
||||
"center" | "middle" => Some(VAlign::Center),
|
||||
"bottom" => Some(VAlign::Bottom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The nine cell alignments (`cellAlign`), vertical then horizontal.
|
||||
pub const CELL_ALIGNS: [(&str, VAlign, Align); 9] = [
|
||||
("topLeft", VAlign::Top, Align::Left),
|
||||
("topCenter", VAlign::Top, Align::Center),
|
||||
("topRight", VAlign::Top, Align::Right),
|
||||
("centerLeft", VAlign::Center, Align::Left),
|
||||
("center", VAlign::Center, Align::Center),
|
||||
("centerRight", VAlign::Center, Align::Right),
|
||||
("bottomLeft", VAlign::Bottom, Align::Left),
|
||||
("bottomCenter", VAlign::Bottom, Align::Center),
|
||||
("bottomRight", VAlign::Bottom, Align::Right),
|
||||
];
|
||||
|
||||
/// One part's params: borders, fill, character formatting and cell alignment.
|
||||
fn region(r: &Value, f: &mut PartFormat) -> Result<(), CmdError> {
|
||||
if !r.is_object() {
|
||||
return Err(CmdError::Params("a part is an object".into()));
|
||||
}
|
||||
let chr = &mut f.chr;
|
||||
if r.get("fill").is_some() {
|
||||
*fill = color("fill")?;
|
||||
f.fill = color_param(r, "fill")?;
|
||||
}
|
||||
if r.get("color").is_some() {
|
||||
chr.color = color("color")?.map(TextColor::Rgb);
|
||||
chr.color = color_param(r, "color")?.map(TextColor::Rgb);
|
||||
}
|
||||
if let Some(b) = p::bool(r, "bold") {
|
||||
chr.bold = Some(b);
|
||||
@@ -315,21 +463,71 @@ fn region(r: &Value, fill: &mut Option<Rgb>, chr: &mut CharProps, borders: &mut
|
||||
chr.italic = Some(b);
|
||||
chr.italic_cs = Some(b);
|
||||
}
|
||||
match r.get("underline") {
|
||||
None => {}
|
||||
Some(Value::Bool(b)) => chr.underline = Some(if *b { Underline::Single } else { Underline::None }),
|
||||
Some(Value::Null) => chr.underline = None,
|
||||
Some(u @ Value::String(_)) => {
|
||||
chr.underline = Some(serde_json::from_value(u.clone()).map_err(|_| CmdError::Params(format!("unknown underline {u}")))?);
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`underline` is a bool or a style name".into())),
|
||||
}
|
||||
if let Some(sz) = p::f32(r, "size") {
|
||||
chr.size = Some(sz.clamp(1.0, 1638.0));
|
||||
chr.size_cs = chr.size;
|
||||
}
|
||||
if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) {
|
||||
chr.font = Some(f.to_string());
|
||||
match r.get("font") {
|
||||
None => {}
|
||||
// Null or empty: the part's font is inherited again.
|
||||
Some(Value::Null) => chr.font = None,
|
||||
Some(Value::String(n)) => chr.font = Some(n.trim()).filter(|n| !n.is_empty()).map(|n| n.chars().take(256).collect()),
|
||||
Some(_) => return Err(CmdError::Params("`font` is a name or null".into())),
|
||||
}
|
||||
let name = |k: &str| -> Result<Option<Option<&str>>, CmdError> {
|
||||
match r.get(k) {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(n)) => Ok(Some(Some(n.as_str()))),
|
||||
Some(_) => Err(CmdError::Params(format!("`{k}` is a name or null"))),
|
||||
}
|
||||
};
|
||||
if let Some(a) = name("align")? {
|
||||
f.align = a.map(|n| h_align(n).ok_or_else(|| CmdError::Params(format!("unknown alignment `{n}`")))).transpose()?;
|
||||
}
|
||||
if let Some(a) = name("vAlign")? {
|
||||
f.v_align = a.map(|n| v_align(n).ok_or_else(|| CmdError::Params(format!("unknown vertical alignment `{n}`")))).transpose()?;
|
||||
}
|
||||
if let Some(a) = name("cellAlign")? {
|
||||
match a {
|
||||
None => (f.align, f.v_align) = (None, None),
|
||||
Some(n) => {
|
||||
let (_, va, ha) = CELL_ALIGNS
|
||||
.iter()
|
||||
.find(|(k, _, _)| k.eq_ignore_ascii_case(n))
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown cell alignment `{n}`")))?;
|
||||
(f.align, f.v_align) = (Some(*ha), Some(*va));
|
||||
}
|
||||
}
|
||||
}
|
||||
match r.get("borders") {
|
||||
Some(Value::Bool(true)) => {
|
||||
let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0);
|
||||
*borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 }));
|
||||
f.borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color_param(r, "borderColor")?, space: 0.0 }));
|
||||
}
|
||||
Some(Value::Bool(false)) => f.borders = Some(Borders::all(NO_LINE)),
|
||||
Some(Value::Null) => f.borders = None,
|
||||
Some(Value::Array(list)) => {
|
||||
let mut b = f.borders.unwrap_or_default();
|
||||
for e in list.iter().take(64) {
|
||||
edges_param(e, &mut b)?;
|
||||
}
|
||||
f.borders = Some(b);
|
||||
}
|
||||
Some(e) => {
|
||||
let mut b = f.borders.unwrap_or_default();
|
||||
edges_param(e, &mut b)?;
|
||||
f.borders = Some(b);
|
||||
}
|
||||
Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })),
|
||||
Some(Value::Null) => *borders = None,
|
||||
Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())),
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -152,7 +152,10 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
// Bullets join an existing bullet list; typing "1. " always starts numbering again at 1,
|
||||
// as in Word.
|
||||
let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None };
|
||||
let num = found.unwrap_or_else(|| s.doc.numbering.add_list(kind));
|
||||
let num = match found {
|
||||
Some(n) => n,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
};
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
let len = para.len();
|
||||
para.delete(0, len)?;
|
||||
@@ -189,7 +192,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
|
||||
} else if before.ends_with(' ')
|
||||
&& let Some(i) = before.trim_end().rfind("--")
|
||||
&& i > 0
|
||||
&& before.trim_end().get(i - 1..i).is_some_and(|c| c != " ")
|
||||
&& before.trim_end()[..i].chars().next_back().is_some_and(|c| c != ' ')
|
||||
{
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
let props = p.props_at(i).clone();
|
||||
@@ -524,7 +527,7 @@ fn sample_text(s: &Session) -> Option<String> {
|
||||
if args.contains(['(', ')']) {
|
||||
return None;
|
||||
}
|
||||
let count = |arg: &str| arg.parse::<usize>().ok().filter(|n| (1..=200).contains(n));
|
||||
let count = |arg: &str| arg.trim().parse::<usize>().ok().filter(|n| (1..=200).contains(n));
|
||||
let counts = if args.is_empty() {
|
||||
Some((5, 3))
|
||||
} else if let Some((p, s)) = args.split_once(',') {
|
||||
@@ -724,8 +727,9 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
s.sel = Selection::caret(at);
|
||||
}
|
||||
Some(Block::Table(_)) => {
|
||||
// Move into the table's last cell.
|
||||
let last = s.doc.para_paths(f.story).into_iter().rfind(|q| q.0.first() == prev_path.0.first() && q.0.len() > 1);
|
||||
// Move into the table's last cell. The table is matched by its whole path: a table
|
||||
// nested in a cell shares only its first index with the table around it.
|
||||
let last = s.doc.para_paths(f.story).into_iter().rfind(|q| q.0.len() > prev_path.0.len() && q.0.starts_with(&prev_path.0));
|
||||
if let Some(q) = last {
|
||||
let off = s.doc.para(f.story, &q).map(|x| x.len()).unwrap_or(0);
|
||||
if s.doc.para_at(&f).is_some_and(|x| x.is_empty()) && s.doc.container(f.story, &f.path).is_some_and(|c| c.len() > i + 1) {
|
||||
|
||||
@@ -361,6 +361,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"name"?: string} (call again to stop)"#)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.macros", "Macros", "View › Macros", macros)
|
||||
.key("Alt+F8")
|
||||
.params(r#"{"run"?: name, "define"?: {"name": string, "steps": [{"command", "params"}]}, "delete"?: name}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
@@ -456,7 +457,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts)
|
||||
.params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#),
|
||||
CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts).params(r#"{"save"?: name, "insert"?: name}"#),
|
||||
CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts)
|
||||
.key(super::non_mac("Alt+F3"))
|
||||
.params(r#"{"save"?: name, "insert"?: name}"#),
|
||||
CommandSpec::new("insert.docProperty", "Document Property", "Insert › Text › Quick Parts", |s, v| {
|
||||
let name = p::str(v, "name").unwrap_or("Title").to_string();
|
||||
let c = &s.doc.core;
|
||||
@@ -476,7 +479,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: val, locked: false, code: false, form: String::new() }, &props)?;
|
||||
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: val, locked: false, dirty: false, code: false, form: String::new() }, &props)?;
|
||||
s.sel = Selection::caret(end);
|
||||
sel_result(s)
|
||||
})
|
||||
@@ -534,6 +537,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({"linked": true}))
|
||||
})
|
||||
.key(super::non_mac("Alt+Shift+R"))
|
||||
.params(r#"{"footer"?: bool}"#),
|
||||
CommandSpec::new("hf.next", "Next Section", "Header & Footer › Navigation", |s, _| hf_nav(s, 1)).pure(),
|
||||
CommandSpec::new("hf.previous", "Previous Section", "Header & Footer › Navigation", |s, _| hf_nav(s, -1)).pure(),
|
||||
@@ -606,7 +610,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal"));
|
||||
let text = p::str(v, "text").unwrap_or("%1.").to_string();
|
||||
let start = p::u64(v, "start").unwrap_or(1).min(100_000) as u32;
|
||||
let id = s.doc.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let id = s.doc.numbering.try_add_list(wordcraft_doc::ListKind::Numbered)?;
|
||||
if let Some(a) = s.doc.numbering.abstract_of(id).map(|a| a.id)
|
||||
&& let Some(abs) = s.doc.numbering.abstracts.iter_mut().find(|x| x.id == a)
|
||||
&& let Some(l) = abs.levels.first_mut()
|
||||
|
||||
@@ -285,7 +285,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => h * 1.6,
|
||||
};
|
||||
}
|
||||
let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, props))]);
|
||||
let id = s.doc.try_add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, props))])?;
|
||||
if let Some(p) = s.doc.parts.get_mut(&id)
|
||||
&& let Some(b) = p.blocks.first_mut()
|
||||
&& let wordcraft_doc::Block::Para(para) = std::sync::Arc::make_mut(b)
|
||||
|
||||
+29
-17
@@ -116,6 +116,14 @@ pub fn can_encrypt(name: &str) -> bool {
|
||||
is_word_package(&ext_of(name))
|
||||
}
|
||||
|
||||
/// Finish a document just read from a file: never leave it empty, and bring its unlocked
|
||||
/// `DATE`/`TIME` fields up to now the way Word does on open (see
|
||||
/// [`crate::cmd::references::refresh_date_fields`]).
|
||||
fn opened(doc: &mut Document) {
|
||||
doc.ensure_nonempty();
|
||||
crate::cmd::references::refresh_date_fields(doc);
|
||||
}
|
||||
|
||||
/// Parse a document from bytes; `name` gives the format by extension.
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
open_bytes_with(name, bytes, None).map(|(doc, _)| doc)
|
||||
@@ -174,7 +182,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
let ext = ext_of(name);
|
||||
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
|
||||
let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?;
|
||||
doc.ensure_nonempty();
|
||||
opened(&mut doc);
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if ext.is_empty() || signature_contradicts(&ext, bytes) {
|
||||
@@ -188,13 +196,17 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
}
|
||||
// Plain text goes through the formats importer: BOMs, unmarked UTF-16, Windows-1252.
|
||||
"txt" | "text" => wordcraft_formats::txt::import_on(bytes, page),
|
||||
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
|
||||
"json" => {
|
||||
let mut d = serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?;
|
||||
d.normalize_runs();
|
||||
d
|
||||
}
|
||||
other => match crate::io_ext::open(other, bytes, page) {
|
||||
Some(r) => r?,
|
||||
None => return open_by_content(name, other, bytes, password, page),
|
||||
},
|
||||
};
|
||||
doc.ensure_nonempty();
|
||||
opened(&mut doc);
|
||||
Ok((doc, false))
|
||||
}
|
||||
|
||||
@@ -285,7 +297,7 @@ fn open_by_content(
|
||||
} else {
|
||||
return Err(format!("{name}: unsupported format `.{ext}` (not a document or text file)"));
|
||||
};
|
||||
doc.ensure_nonempty();
|
||||
opened(&mut doc);
|
||||
Ok((doc, encrypted))
|
||||
}
|
||||
|
||||
@@ -347,7 +359,7 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor
|
||||
let html = wordcraft_formats::html::decode(&bytes);
|
||||
let mut doc = wordcraft_formats::html::import_on(&html, &|src| images.load(src), ®ion_page(region));
|
||||
fit_pictures(&mut doc);
|
||||
doc.ensure_nonempty();
|
||||
opened(&mut doc);
|
||||
return Ok((doc, false));
|
||||
}
|
||||
open_bytes_in(&name, &bytes, password, region)
|
||||
@@ -485,6 +497,18 @@ pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, Str
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
|
||||
/// read root when there is one: enough to tell a file's format by its start.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read;
|
||||
crate::file_access::check_read(path)?;
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut head = Vec::new();
|
||||
f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
/// Rename without replacing another file, respecting both automation access roots.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> {
|
||||
@@ -519,18 +543,6 @@ fn only_case_differs(source: &std::path::Path, destination: &std::path::Path) ->
|
||||
std::fs::read_dir(dir).is_ok_and(|entries| entries.filter_map(Result::ok).all(|entry| entry.file_name() != to))
|
||||
}
|
||||
|
||||
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
|
||||
/// read root when there is one: enough to tell a file's format by its start.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read;
|
||||
crate::file_access::check_read(path)?;
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut head = Vec::new();
|
||||
f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
|
||||
@@ -14,6 +14,8 @@ const MAX_BYTES: u64 = 256 << 20;
|
||||
const MAX_RECORDS: usize = 128;
|
||||
const MAX_FILES: usize = 16;
|
||||
const DISK_BUDGET: u64 = 2 << 30;
|
||||
/// Times a record lock held for a moment (by a process being started) is tried again.
|
||||
const LOCK_RETRIES: u32 = 10;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -127,11 +129,21 @@ impl Store {
|
||||
paths.sort_by_key(|p| std::cmp::Reverse(generation(p)));
|
||||
Ok(paths)
|
||||
}
|
||||
/// Locks a record's `owner.lock`, retrying [`LOCK_RETRIES`] times 10 ms apart.
|
||||
fn lock(&self, id: &str) -> Result<File, String> {
|
||||
if !id_ok(id) {
|
||||
return Err("invalid recovery id".into());
|
||||
}
|
||||
let file = private_file(&self.root.join(id).join("owner.lock"), false).map_err(|e| e.to_string())?;
|
||||
// A process another part of the app (or a test) is starting holds the lock for a moment
|
||||
// (a forked child shares it until it execs): wait that out, but not a live owner.
|
||||
for _ in 0..LOCK_RETRIES {
|
||||
match file.try_lock() {
|
||||
Ok(()) => return Ok(file),
|
||||
Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(10)),
|
||||
Err(e) => return Err(format!("recovery copy is in use or cannot be locked: {e}")),
|
||||
}
|
||||
}
|
||||
file.try_lock().map_err(|e| format!("recovery copy is in use or cannot be locked: {e}"))?;
|
||||
Ok(file)
|
||||
}
|
||||
@@ -558,6 +570,33 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// A store reopened on `root` whose scan reports an unreadable record. The record's lock can be
|
||||
/// briefly held by a process another test is starting (a forked child holds the descriptor
|
||||
/// until it execs), and a record that can't be locked is skipped, not reported: so try again
|
||||
/// for up to a second rather than read a skipped record as a clean scan.
|
||||
fn reopen_reporting_unreadable(root: &std::path::Path) -> Store {
|
||||
for _ in 0..50 {
|
||||
let mut store = Store::new(root.to_path_buf()).unwrap();
|
||||
if store.scan().is_err() {
|
||||
return store;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("the scan never reported the unreadable record");
|
||||
}
|
||||
/// `store.load(id)`, tried again for up to a second while the record can't be locked: a process
|
||||
/// another test is starting holds the lock for a moment (see [`reopen_reporting_unreadable`]).
|
||||
fn load_retrying(store: &mut Store, id: &str) -> Snapshot {
|
||||
let mut last = String::new();
|
||||
for _ in 0..50 {
|
||||
match store.load(id) {
|
||||
Ok(snapshot) => return snapshot,
|
||||
Err(e) => last = e,
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("the record never loaded: {last}");
|
||||
}
|
||||
fn snapshot(id: &str, text: &str) -> Snapshot {
|
||||
Snapshot {
|
||||
version: 1,
|
||||
@@ -641,9 +680,8 @@ mod tests {
|
||||
let second = store.entries[&id].path.clone();
|
||||
fs::write(second, b"{truncated").unwrap();
|
||||
drop(store);
|
||||
let mut reopened = Store::new(t.0.clone()).unwrap();
|
||||
assert!(reopened.scan().is_err());
|
||||
assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "First valid copy");
|
||||
let mut reopened = reopen_reporting_unreadable(&t.0);
|
||||
assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "First valid copy");
|
||||
// Never reuse the corrupt generation's name or prune the valid fallback.
|
||||
reopened.save(snapshot(&id, "Third valid copy")).unwrap();
|
||||
assert_eq!(fs::read(first).unwrap(), bytes);
|
||||
@@ -683,21 +721,8 @@ mod tests {
|
||||
assert!(store.save(snapshot(&id, "New copy")).is_err());
|
||||
assert_eq!(fs::read(first).unwrap(), bytes);
|
||||
drop(store);
|
||||
// The record's lock can be briefly held by a process another test is starting (a forked
|
||||
// child holds the descriptor until it execs), and a record that can't be locked is
|
||||
// skipped, not reported: try again rather than read that as success.
|
||||
let mut reopened = Store::new(t.0.clone()).unwrap();
|
||||
let mut scanned = reopened.scan();
|
||||
for _ in 0..50 {
|
||||
if scanned.is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
reopened = Store::new(t.0.clone()).unwrap();
|
||||
scanned = reopened.scan();
|
||||
}
|
||||
assert!(scanned.is_err());
|
||||
assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "Last valid copy");
|
||||
let mut reopened = reopen_reporting_unreadable(&t.0);
|
||||
assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Last valid copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -724,7 +749,7 @@ mod tests {
|
||||
let mut reopened = Store::new(t.0.clone()).unwrap();
|
||||
reopened.scan().unwrap();
|
||||
assert!(!dir.exists());
|
||||
assert_eq!(reopened.load(&id).unwrap().document.plain_text(StoryRef::Body), "Valid synthetic draft");
|
||||
assert_eq!(load_retrying(&mut reopened, &id).document.plain_text(StoryRef::Body), "Valid synthetic draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -122,7 +122,7 @@ pub fn sample_document() -> Document {
|
||||
f.props.align = Some(Align::Center);
|
||||
let _ = f.insert_object(
|
||||
0,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false, form: String::new() },
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
);
|
||||
let fid = d.add_part(PartKind::Footer, vec![para_block(f)]);
|
||||
|
||||
@@ -351,8 +351,8 @@ pub struct Session {
|
||||
pub pending_move: Option<crate::cmd::moves::PendingMove>,
|
||||
/// Plain text mirror of the clipboard (for the system clipboard).
|
||||
pub clipboard_text: String,
|
||||
/// HTML for the system clipboard beside [`Self::clipboard_text`] when the copy holds an
|
||||
/// equation (as MathML, #816), for programs that read MathML; empty otherwise.
|
||||
/// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for
|
||||
/// programs that read HTML, equations as MathML (#816); empty when nothing was copied.
|
||||
pub clipboard_html: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
@@ -1321,7 +1321,12 @@ impl Session {
|
||||
// As in Word, text typed at either end of a hyperlink isn't part of it; only typing
|
||||
// inside the link extends it.
|
||||
let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link;
|
||||
if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }
|
||||
let mut out = if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() };
|
||||
// A footnote or endnote number is raised by its character style; text typed after it is body text.
|
||||
if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) {
|
||||
out.style = None;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]).
|
||||
|
||||
@@ -256,6 +256,7 @@ struct Shared {
|
||||
generation: u64,
|
||||
error: Option<String>,
|
||||
/// A worker thread is running (cleared under the lock as it exits).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
running: bool,
|
||||
}
|
||||
|
||||
|
||||
+740
-4
@@ -1,3 +1,8 @@
|
||||
// Reuse our hand-written Czech proof fixture; its install helper resolves this parent import.
|
||||
use wordcraft_proof::cs::install_dictionary;
|
||||
#[path = "../../proof/src/cs/test_dict.rs"]
|
||||
mod czech_test_dict;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
@@ -117,14 +122,50 @@ fn word_default_shortcuts_resolve() {
|
||||
assert_eq!(id(cmd::mac_or("Ctrl+Q", "Mod+Q")), Some("para.reset"));
|
||||
assert_eq!(id(cmd::mac_or("Ctrl+Space", "Mod+Space")), Some("format.resetChar"));
|
||||
assert_eq!(id(cmd::mac_or("Ctrl+Shift+D", "Alt+Shift+D")), Some("insert.dateField"));
|
||||
// Option+arrows move by word everywhere; ⌘←/⌘→ go to the line's ends on macOS (#954).
|
||||
assert_eq!(id("Alt+Left"), Some("caret.wordLeft"));
|
||||
assert_eq!(id("Alt+Right"), Some("caret.wordRight"));
|
||||
assert_eq!(id("Mod+Left"), Some(cmd::mac_or("caret.home", "caret.wordLeft")));
|
||||
assert_eq!(id("Mod+Right"), Some(cmd::mac_or("caret.end", "caret.wordRight")));
|
||||
assert_eq!(id("Shift+F1"), Some("styles.inspector"));
|
||||
assert_eq!(id("Alt+F8"), Some("tools.macros"));
|
||||
assert_eq!(id("Mod+Shift+X"), Some("format.strikethrough"));
|
||||
assert_eq!(id("Shift+F4"), Some("edit.findNext"));
|
||||
assert_eq!(id("Mod+Insert"), Some("edit.copy"));
|
||||
assert_eq!(id("Shift+Insert"), Some("edit.paste"));
|
||||
assert_eq!(id("Mod+Alt+Z"), Some("edit.goBack"));
|
||||
assert_eq!(id("Mod+Alt+F2"), Some("file.open"));
|
||||
assert_eq!(id("Alt+Shift+F2"), Some("file.save"));
|
||||
assert_eq!(id("Mod+Alt+I"), Some("file.print"));
|
||||
assert_eq!(id("Alt+Shift+F1"), Some("fields.previous"));
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(id("Mod+Shift+G"), Some("edit.findPrevious"));
|
||||
// Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E endnote (⌘⌥D hides the Dock), ⌘⌥A comment, ⌃H Replace.
|
||||
assert_eq!(id("Mod+Shift+S"), Some("file.saveAs"));
|
||||
assert_eq!(id("Mod+Alt+E"), Some("references.endnote"));
|
||||
assert_eq!(id("Mod+Alt+D"), None);
|
||||
assert_eq!(id("Mod+Alt+A"), Some("review.newComment"));
|
||||
assert_eq!(id("Ctrl+H"), Some("edit.findReplace"));
|
||||
assert_eq!(id("Mod+Alt+L"), Some("review.spelling"));
|
||||
} else {
|
||||
assert_eq!(id("Mod+Shift+S"), Some("styles.apply"));
|
||||
assert_eq!(id("Mod+Alt+D"), Some("references.endnote"));
|
||||
assert_eq!(id("Mod+Alt+A"), None);
|
||||
assert_eq!(id("Mod+Shift+G"), Some("review.wordCount"));
|
||||
assert_eq!(id("Mod+Alt+C"), Some("text.copyright"));
|
||||
assert_eq!(id("Mod+Alt+H"), Some("format.highlight"));
|
||||
assert_eq!(id("Alt+Shift+L"), Some("outline.firstLineOnly"));
|
||||
assert_eq!(id("Alt+Shift+>"), Some("references.nextFootnote"));
|
||||
assert_eq!(id("Alt+Shift+."), Some("references.nextFootnote"));
|
||||
assert_eq!(id("Alt+Shift+R"), Some("layout.linkToPrevious"));
|
||||
assert_eq!(id("Alt+F3"), Some("insert.autoText"));
|
||||
assert_eq!(id("Alt+F2"), Some("file.saveAs"));
|
||||
assert_eq!(id("Alt+F1"), Some("fields.next"));
|
||||
}
|
||||
assert_eq!(id("Mod+PageUp"), Some("edit.findPrevious"));
|
||||
assert_eq!(id("F12"), Some("file.saveAs"));
|
||||
assert_eq!(id("Mod+Alt+M"), Some("review.newComment"));
|
||||
assert_eq!(id("Mod+H"), Some("edit.findReplace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -530,6 +571,25 @@ fn backspace_and_delete() {
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
/// Backspace at the start of the paragraph after a table nested in a cell goes into the nested
|
||||
/// table's last cell, not into the last cell of the table around it.
|
||||
#[test]
|
||||
fn backspace_after_a_nested_table_stays_in_its_cell() {
|
||||
let mut s = s();
|
||||
run(&mut s, "table.quick", json!({"kind": "matrix"}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 0}}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let outer = s.doc.body.get(1).and_then(|b| b.as_table()).expect("the outer table");
|
||||
let blocks = &outer.rows[1].cells[1].blocks;
|
||||
let nested = blocks.iter().position(|b| b.as_table().is_some()).expect("a nested table");
|
||||
assert!(nested + 1 < blocks.len(), "a paragraph follows the nested table");
|
||||
let after = nested as u32 + 1;
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, after], "off": 0}}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
// The nested table's last cell: row 1, cell 1, its first paragraph.
|
||||
assert_eq!(s.sel.focus.path.0, vec![1, 1, 1, after - 1, 1, 1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_toggles_selection_and_caret_word() {
|
||||
let mut s = s();
|
||||
@@ -882,10 +942,23 @@ fn copying_an_equation_puts_mathml_on_the_clipboard() {
|
||||
run(&mut s, "edit.paste", json!({"text": clip, "html": html}));
|
||||
assert_eq!(equations(&s), vec!["y=2x_s".to_string(), "y=2x_s".to_string()]);
|
||||
|
||||
// A copy without an equation carries no HTML.
|
||||
// A copy without an equation carries HTML too (#1523).
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert!(r.get("html").is_none() && s.clipboard_html.is_empty(), "{r}");
|
||||
assert!(r["html"].as_str().is_some_and(|h| h.contains("Speed") && !h.contains("<math")), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_bold_text_puts_html_on_the_clipboard() {
|
||||
// #1523: formatted text reaches other programs as HTML, not only as plain text.
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "plain bold"}));
|
||||
s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
let html = r["html"].as_str().unwrap_or_default().to_string();
|
||||
assert!(html.contains("<strong>bold</strong>"), "{html}");
|
||||
assert_eq!(s.clipboard_html, html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1468,7 +1541,7 @@ fn table_properties_apply_in_one_step() {
|
||||
let row = &t.rows[0];
|
||||
assert_eq!((row.props.height, row.props.height_rule, row.props.cant_split, row.props.header), (Some(40.0), HeightRule::Exact, true, true));
|
||||
assert_eq!(t.grid[0], 120.0);
|
||||
assert_eq!((row.cells[0].props.width, row.cells[0].props.valign), (Some(110.0), VAlign::Bottom));
|
||||
assert_eq!((row.cells[0].props.width, row.cells[0].props.valign), (Some(110.0), Some(VAlign::Bottom)));
|
||||
let applied = t.clone();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.body.iter().find_map(|b| b.as_table()).unwrap(), &t0, "one undo step per apply");
|
||||
@@ -1718,6 +1791,18 @@ fn track_changes_and_accept() {
|
||||
assert_eq!(text(&s), "inal added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_text_drops_tracked_deletions() {
|
||||
// #866: document.text (MCP get_text) omits tracked deletions; plain_text still includes them.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Signed by Alice Smith, Partner."}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "select.text", json!({"text": "Alice Smith, Partner"}));
|
||||
run(&mut s, "text.insert", json!({"text": "Bob Jones, Director"}));
|
||||
assert_eq!(text(&s), "Signed by Bob Jones, DirectorAlice Smith, Partner.");
|
||||
assert_eq!(run(&mut s, "document.text", json!({}))["text"], "Signed by Bob Jones, Director.");
|
||||
}
|
||||
|
||||
/// The author of the revision `rid` points to.
|
||||
fn author_of(s: &Session, rid: Option<u32>) -> Option<String> {
|
||||
rid.and_then(|r| s.doc.revisions.get(r as usize)).map(|r| r.author.clone())
|
||||
@@ -2272,7 +2357,7 @@ fn imported_range_toc_is_refreshed_in_place() {
|
||||
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 }, &CharProps::default()).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-2" \h"#.into(), locked, dirty: false }, &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
for (i, style) in [(2, "Heading 1"), (3, "Heading 2")] {
|
||||
@@ -2306,6 +2391,224 @@ fn imported_range_toc_is_refreshed_in_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn update_toc_clears_stale_field_result_for_docx_span() {
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let mut d = wordcraft_doc::Document::from_text("\nAlpha\nBeta");
|
||||
{
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
p.insert_object(
|
||||
0,
|
||||
InlineObject::Field {
|
||||
instr: r#"TOC \o "1-3" \h \z \u"#.into(),
|
||||
result: "Placeholder: update fields (F9).".into(),
|
||||
locked: false,
|
||||
dirty: false,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
for (i, style) in [(1, "Heading 1"), (2, "Heading 2")] {
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
|
||||
run(&mut s, "styles.apply", json!({"style": style}));
|
||||
}
|
||||
// Before update: the field result is present in plain text.
|
||||
assert!(text(&s).contains("Placeholder: update fields (F9)."));
|
||||
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
|
||||
// After update: the placeholder is gone, replaced by regenerated entries.
|
||||
let refreshed = text(&s);
|
||||
assert!(!refreshed.contains("Placeholder"), "{refreshed}");
|
||||
assert!(refreshed.contains("Alpha\t"), "{refreshed}");
|
||||
assert!(refreshed.contains("Beta\t"), "{refreshed}");
|
||||
|
||||
// DOCX round-trip: entries must be enclosed within the TOC field rather than outside it.
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let xml = {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).unwrap();
|
||||
let mut file = archive.by_name("word/document.xml").unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut file, &mut s).unwrap();
|
||||
s
|
||||
};
|
||||
let b = xml.find(r#"w:fldCharType="begin""#).expect("TOC begin marker present");
|
||||
let e = xml.find(r#"w:fldCharType="end""#).expect("TOC end marker present");
|
||||
assert!(b < e, "begin marker precedes end marker");
|
||||
let inside_field = &xml[b..e];
|
||||
let after_field = &xml[e..];
|
||||
assert!(inside_field.contains("Alpha"), "Alpha entry is inside TOC field");
|
||||
assert!(inside_field.contains("Beta"), "Beta entry is inside TOC field");
|
||||
assert!(!inside_field.contains("Placeholder"), "Placeholder is not inside TOC field");
|
||||
assert!(!after_field.contains(r#"w:val="TOC1""#), "no TOC1 styles after the field end marker");
|
||||
}
|
||||
|
||||
/// #877: computing a field's result clears `w:dirty` — the result is no longer stale, like
|
||||
/// Word's F9. A field WordCraft can't compute keeps the flag so Word refreshes it on open.
|
||||
#[test]
|
||||
fn update_fields_clears_dirty() {
|
||||
let mut s = s();
|
||||
s.doc.core.title = "Report".into();
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
for instr in ["TITLE", "NUMWORDS", "NOTAFIELD \\x"] {
|
||||
let end = p.len();
|
||||
p.insert_object(
|
||||
end,
|
||||
wordcraft_doc::InlineObject::Field {
|
||||
instr: instr.into(),
|
||||
result: String::new(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&wordcraft_doc::props::CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
s.doc.body = vec![wordcraft_doc::para_block(p)];
|
||||
run(&mut s, "references.updateFields", json!({}));
|
||||
let objs = &s.doc.body[0].as_para().unwrap().objects;
|
||||
let dirty: Vec<bool> = objs.iter().map(|o| matches!(o, wordcraft_doc::InlineObject::Field { dirty: true, .. })).collect();
|
||||
assert_eq!(dirty, vec![false, false, true], "computed fields clear the flag, unknown ones keep it: {:?}", objs);
|
||||
assert!(matches!(objs.first(), Some(wordcraft_doc::InlineObject::Field { result, .. }) if result == "Report"), "{objs:?}");
|
||||
}
|
||||
|
||||
/// #877/#878: an imported TOC field whose begin marker carries `w:dirty` (a generator wrote a
|
||||
/// placeholder result and asked Word to refresh it on open) keeps the flag through saves until
|
||||
/// WordCraft computes the entries: Update Table replaces the placeholder in place (#878) and
|
||||
/// clears the flag, like Word's F9. Undo brings the dirty imported field back.
|
||||
#[test]
|
||||
fn imported_dirty_toc_updates_in_place_and_clears() {
|
||||
let mut d = wordcraft_doc::Document::from_text("Placeholder: update fields (F9).\nAlpha\nBody text.\nBeta\nGamma");
|
||||
{
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_object(n, wordcraft_doc::InlineObject::FieldEnd, &wordcraft_doc::props::CharProps::default()).unwrap();
|
||||
p.insert_object(
|
||||
0,
|
||||
wordcraft_doc::InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: true },
|
||||
&wordcraft_doc::props::CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
for (i, style) in [(1, "Heading 1"), (3, "Heading 2"), (4, "Heading 1")] {
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
|
||||
run(&mut s, "styles.apply", json!({"style": style}));
|
||||
}
|
||||
// As an imported file: the flag survives the round trip, and a plain save keeps it.
|
||||
s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||||
let toc_dirty = |d: &wordcraft_doc::Document| {
|
||||
d.body.iter().find_map(|b| {
|
||||
b.as_para()?.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::FieldStart { dirty, .. } => Some(*dirty),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
};
|
||||
assert_eq!(toc_dirty(&s.doc), Some(true), "the imported TOC reads back dirty");
|
||||
let saved = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(toc_dirty(&saved), Some(true), "a plain save keeps the flag for Word to refresh");
|
||||
// Update Table replaces the placeholder in place and clears the flag.
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let t = text(&s);
|
||||
assert!(!t.contains("Placeholder"), "{t}");
|
||||
for entry in ["Alpha\t", "Beta\t", "Gamma\t"] {
|
||||
assert_eq!(t.matches(entry).count(), 1, "{t}");
|
||||
}
|
||||
assert!(s.doc.field_ranges(StoryRef::Body).is_empty(), "now the engine's TOC");
|
||||
let updated = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(toc_dirty(&updated), Some(false), "the computed TOC no longer asks Word to refresh it");
|
||||
// Undo brings the dirty imported field back.
|
||||
while s.undo() {}
|
||||
assert_eq!(toc_dirty(&s.doc), Some(true));
|
||||
assert!(text(&s).contains("Placeholder"));
|
||||
}
|
||||
|
||||
/// A TOC as Word and other writers lay it out (a TOC heading, then the field in paragraphs of its
|
||||
/// own), styles with a number tab stop and a hanging indent, and a wider last section with the
|
||||
/// caret in it (Select All, Update Field): no blank line between the heading and the entries,
|
||||
/// and every page number at the TOC section's right margin, after its leader: short entries,
|
||||
/// entries that wrap, and level-one entries whose style has no stops of its own.
|
||||
#[test]
|
||||
fn toc_page_numbers_reach_the_right_margin() {
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
let long = "A deliberately long section title that does not fit on one line of the table of contents and wraps onto a second line";
|
||||
let mut d = wordcraft_doc::Document::from_text(&format!("Contents\n\nNo entries.\n\nShort\n{long}\nTop\nAppendix"));
|
||||
{
|
||||
let toc2 = d.styles.get_mut("TOC2").unwrap();
|
||||
toc2.para.tabs = Some(vec![
|
||||
TabStop { pos: 77.0, align: TabAlign::Left, leader: TabLeader::None },
|
||||
TabStop { pos: 467.2, align: TabAlign::Right, leader: TabLeader::Dot },
|
||||
]);
|
||||
toc2.para.indent_left = Some(36.9);
|
||||
toc2.para.indent_first = Some(-22.7);
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false, dirty: false }, &CharProps::default())
|
||||
.unwrap();
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(3)).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldEnd, &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut s = Session::new(d);
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
|
||||
run(&mut s, "styles.apply", json!({"style": "TOC Heading"}));
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(1, 0)}));
|
||||
run(&mut s, "styles.apply", json!({"style": "TOC 1"}));
|
||||
for (i, style) in [(4, "Heading 2"), (5, "Heading 2"), (6, "Heading 1"), (7, "Heading 1")] {
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(i, 0)}));
|
||||
run(&mut s, "styles.apply", json!({"style": style}));
|
||||
}
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(7, 0)}));
|
||||
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "layout.orientation", json!({"value": "landscape"}));
|
||||
// Through DOCX, as an imported file is.
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "references.updateFields", json!({}));
|
||||
|
||||
let body: Vec<(String, String)> =
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.props.style.clone().unwrap_or_default(), p.plain_text())).collect();
|
||||
assert_eq!(body[0], ("TOCHeading".into(), "Contents".into()), "{body:?}");
|
||||
assert!(body[1].0.starts_with("TOC") && !body[1].1.is_empty(), "the first entry right under the heading: {body:?}");
|
||||
let toc_width = s.doc.sections()[0].1.text_width();
|
||||
let margin = s.doc.sections()[0].1.margin_left;
|
||||
let l = s.layout();
|
||||
for (i, b) in s.doc.body.iter().enumerate() {
|
||||
let Some(p) = b.as_para() else { continue };
|
||||
if !p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading") {
|
||||
continue;
|
||||
}
|
||||
let page = p.text.rfind('\t').unwrap() + 1;
|
||||
let end = l.caret(&Pos::body(i, p.len())).unwrap();
|
||||
let number = l.caret(&Pos::body(i, page)).unwrap();
|
||||
assert!(
|
||||
(end.x - (margin + toc_width)).abs() < 2.0,
|
||||
"{:?}: page number ends at {} (right margin {})",
|
||||
p.plain_text(),
|
||||
end.x,
|
||||
margin + toc_width
|
||||
);
|
||||
assert!(end.page == number.page && number.x > margin + toc_width - 30.0, "{:?}: page number at {}", p.plain_text(), number.x);
|
||||
}
|
||||
// Updating again keeps the same table.
|
||||
let before = text(&s);
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
assert_eq!(text(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_page_numbers_follow_headings() {
|
||||
let mut s = s();
|
||||
@@ -2394,6 +2697,7 @@ fn hostile_params_never_panic() {
|
||||
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
|
||||
json!({"value": -1e308, "rows": 1e9}),
|
||||
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
|
||||
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
|
||||
];
|
||||
for spec in reg.all() {
|
||||
// These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`)
|
||||
@@ -2466,6 +2770,41 @@ fn text_box_insert_puts_the_caret_inside() {
|
||||
assert_eq!(s.sel.focus, Pos { story: StoryRef::Part(id), path: wordcraft_doc::Path::top(0), off: 4 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_after_the_largest_ids_take_free_ones() {
|
||||
// A file can carry any ids, 0 and u32::MAX included: comments, notes, boxes and lists the
|
||||
// commands add take free ids, without overflowing or replacing what has the id they wrap to.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Some words here"}));
|
||||
for id in [0, u32::MAX] {
|
||||
let blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())];
|
||||
s.doc.parts.insert(id, wordcraft_doc::Part { kind: wordcraft_doc::PartKind::Comment, blocks, ..Default::default() });
|
||||
s.doc.comments.insert(id, wordcraft_doc::Comment { part: id, ..Default::default() });
|
||||
}
|
||||
s.doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
s.doc.numbering.abstracts[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].abstract_id = u32::MAX;
|
||||
let before = s.doc.clone();
|
||||
assert_eq!(run(&mut s, "review.newComment", json!({"text": "new"})), json!({"id": 1, "story": 1}));
|
||||
assert_eq!(run(&mut s, "review.reply", json!({"id": 0, "text": "re"})), json!({"id": 2}));
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
assert_eq!(run(&mut s, "list.define", json!({"levels": []}))["num"], 2);
|
||||
assert_eq!(run(&mut s, "references.footnote", json!({"text": "note"}))["id"], 3);
|
||||
for id in ["insert.wordArt", "insert.textBox"] {
|
||||
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 0)}));
|
||||
run(&mut s, id, json!({"text": "Box"}));
|
||||
}
|
||||
assert_eq!((s.doc.parts.len(), s.doc.comments.len()), (before.parts.len() + 5, 4));
|
||||
assert!(before.parts.iter().all(|(k, p)| s.doc.parts.get(k) == Some(p)));
|
||||
assert!(before.comments.iter().all(|(k, c)| s.doc.comments.get(k) == Some(c)));
|
||||
let n = &s.doc.numbering;
|
||||
assert!(before.numbering.nums.iter().all(|x| n.nums.contains(x)) && before.numbering.abstracts.iter().all(|a| n.abstracts.contains(a)));
|
||||
let unique = |ids: Vec<u32>| ids.iter().collect::<std::collections::HashSet<_>>().len() == ids.len();
|
||||
assert!(unique(n.nums.iter().map(|x| x.id).collect()) && unique(n.abstracts.iter().map(|a| a.id).collect()), "{n:?}");
|
||||
assert_eq!(n.nums.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_in_a_table_cell_shows_its_text() {
|
||||
let mut s = s();
|
||||
@@ -2719,6 +3058,50 @@ fn deleted_text_box_takes_its_text_with_it_until_undo() {
|
||||
assert_eq!(s.doc.word_count_including_notes(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_in_a_table_nested_seventeen_deep_survives_an_edit_and_a_save() {
|
||||
// #1339: opened from a .docx, a box shown only from a table nested deeper than 16 was taken
|
||||
// for unshown, so the next unrelated edit pruned its story and saving dropped its text.
|
||||
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, PartKind, Path, Table, para_block};
|
||||
let mut d = Document::from_text("Intro");
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Deep box", Default::default()))]);
|
||||
let shape = InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::TextBox,
|
||||
w: 72.0,
|
||||
h: 36.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 0.75,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let mut p = Paragraph::with_text("Cell", Default::default());
|
||||
p.insert_object(0, shape, &Default::default()).unwrap();
|
||||
d.body.extend((0..17).fold(vec![para_block(p)], |inner, _| {
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = inner;
|
||||
vec![std::sync::Arc::new(Block::Table(t))]
|
||||
}));
|
||||
// The innermost cell's paragraph: the table after "Intro", then row, cell and block 0 down.
|
||||
let path = Path([vec![1], vec![0; 17 * 3]].concat());
|
||||
let deep_box = |doc: &Document| {
|
||||
let id = doc.para(StoryRef::Body, &path)?.objects.iter().find_map(InlineObject::text_box)?;
|
||||
doc.parts.get(&id).filter(|p| p.kind == PartKind::TextBox).map(|_| doc.plain_text(StoryRef::Part(id)))
|
||||
};
|
||||
let mut s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
assert_eq!(deep_box(&s.doc).as_deref(), Some("Deep box"), "opened");
|
||||
let unshown = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Shown nowhere", Default::default()))]);
|
||||
run(&mut s, "text.insert", json!({"text": "Edited. "}));
|
||||
assert_eq!(deep_box(&s.doc).as_deref(), Some("Deep box"), "kept through the edit");
|
||||
assert!(!s.doc.parts.contains_key(&unshown), "a box shown nowhere still goes");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(deep_box(&back).as_deref(), Some("Deep box"), "saved");
|
||||
assert!(back.plain_text(StoryRef::Body).starts_with("Edited. Intro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_with_an_object_selected_counts_the_document() {
|
||||
let mut s = s();
|
||||
@@ -3469,6 +3852,156 @@ fn custom_table_style_applies_modifies_and_saves() {
|
||||
assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle");
|
||||
}
|
||||
|
||||
/// #714: Modify Table Style edits one part at a time (header row, even bands, a corner cell…)
|
||||
/// with font, fill, borders and cell alignment, and every table using the style follows.
|
||||
/// #1288: a cell's own Top alignment beats a table style that centres vertically, and it
|
||||
/// survives a .docx round trip (`w:vAlign w:val="top"`).
|
||||
#[test]
|
||||
fn explicit_cell_top_beats_a_centred_table_style() {
|
||||
use wordcraft_doc::props::VAlign;
|
||||
fn text_y(s: &mut Session) -> f32 {
|
||||
s.layout().pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
crate::layout::Placed::Lines { para, y, .. } if para.text_len == 4 => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
run(&mut s, "text.insert", json!({"text": "Cell"}));
|
||||
run(&mut s, "table.properties", json!({"rowHeight": 120.0, "rowHeightRule": "exact"}));
|
||||
let top = text_y(&mut s);
|
||||
run(&mut s, "table.newStyle", json!({"name": "Mid", "wholeTable": {"vAlign": "center"}}));
|
||||
let centred = text_y(&mut s);
|
||||
assert!(centred > top + 20.0, "the style centres the text: {top} {centred}");
|
||||
run(&mut s, "table.cellAlign", json!({"value": "topLeft"}));
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!(t.rows[0].cells[0].props.valign, Some(VAlign::Top));
|
||||
assert!((text_y(&mut s) - top).abs() < 0.5, "the cell's Top wins over the style");
|
||||
// It saves and comes back as an explicit Top.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let t = back.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
assert_eq!((t.rows[0].cells[0].props.valign, t.rows[0].cells[1].props.valign), (Some(VAlign::Top), None));
|
||||
let mut back = Session::new(back);
|
||||
assert!((text_y(&mut back) - top).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_style_parts_format_every_table_using_the_style() {
|
||||
use wordcraft_doc::props::{Align, BorderStyle, VAlign};
|
||||
use wordcraft_doc::styles::TablePart;
|
||||
use wordcraft_doc::{Rgb, TextColor};
|
||||
let mut s = s();
|
||||
let look = json!({"headerRow": true, "bandedRows": true, "firstColumn": true, "totalRow": false});
|
||||
run(&mut s, "insert.table", json!({"rows": 4, "cols": 3}));
|
||||
run(&mut s, "text.insert", json!({"text": "Head"}));
|
||||
run(&mut s, "table.look", look.clone());
|
||||
run(&mut s, "table.newStyle", json!({"name": "Parts", "basedOn": "Table Grid"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 4, "cols": 3}));
|
||||
run(&mut s, "text.insert", json!({"text": "Head"}));
|
||||
run(&mut s, "table.look", look);
|
||||
run(&mut s, "table.style", json!({"style": "Parts"}));
|
||||
let tables: Vec<_> = s.doc.body.iter().filter_map(|b| b.as_table()).collect();
|
||||
assert_eq!(tables.len(), 2);
|
||||
assert!(tables.iter().all(|t| t.props.style.as_deref() == Some("Parts")));
|
||||
|
||||
run(
|
||||
&mut s,
|
||||
"table.modifyStyle",
|
||||
json!({"style": "Parts",
|
||||
"firstRow": {"bold": true, "underline": true, "color": "FFFFFF", "font": "Liberation Serif", "cellAlign": "bottomCenter",
|
||||
"borders": [{"edges": "none"}, {"edges": "bottom", "style": "double", "width": 1.5, "color": "FF0000"}]},
|
||||
"band2Horz": {"fill": "DDEEFF"},
|
||||
"nwCell": {"fill": "112233"},
|
||||
"parts": [{"part": "lastCol", "italic": true}]}),
|
||||
);
|
||||
run(&mut s, "table.modifyStyle", json!({"style": "Parts", "part": "band1Horz", "fill": "AABBCC", "vAlign": "center"}));
|
||||
let st = s.doc.styles.get("Parts").unwrap().clone();
|
||||
let head = st.table_part(TablePart::FirstRow);
|
||||
assert_eq!((head.chr.bold, head.align, head.v_align), (Some(true), Some(Align::Center), Some(VAlign::Bottom)));
|
||||
let b = head.borders.unwrap();
|
||||
assert_eq!(b.bottom.map(|x| (x.style, x.width, x.color)), Some((BorderStyle::Double, 1.5, Some(Rgb(0xFF, 0, 0)))));
|
||||
assert_eq!(b.top.map(|x| x.style), Some(BorderStyle::None), "`none` clears the other edges");
|
||||
assert_eq!(st.table_part(TablePart::LastCol).chr.italic, Some(true));
|
||||
assert_eq!(st.table_part(TablePart::Band1Horz).v_align, Some(VAlign::Center));
|
||||
|
||||
let fills = |s: &mut Session, c: Rgb| {
|
||||
s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count()
|
||||
};
|
||||
// Per table: the top-left corner cell; odd bands are rows 1 and 3, the even band row 2.
|
||||
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "top left cells");
|
||||
assert_eq!(fills(&mut s, Rgb(0xAA, 0xBB, 0xCC)), 12, "odd banded rows");
|
||||
assert_eq!(fills(&mut s, Rgb(0xDD, 0xEE, 0xFF)), 6, "even banded rows");
|
||||
let rules = s
|
||||
.layout()
|
||||
.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| matches!(i, crate::layout::Placed::Rule { border, .. } if border.style == BorderStyle::Double && border.color == Some(Rgb(0xFF, 0, 0))))
|
||||
.count();
|
||||
assert!(rules >= 6, "the header rows' bottom edges in both tables: {rules}");
|
||||
// The header text: bold, white, underlined and centred, in both tables.
|
||||
let heads: Vec<_> = s.layout().pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
crate::layout::Placed::Lines { para, .. } if para.text_len == 4 => Some((para.rp.align, para.styles[0].rc.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(heads.len(), 2, "{heads:?}");
|
||||
for (align, rc) in heads {
|
||||
assert_eq!(align, Align::Center);
|
||||
assert!(rc.bold && rc.underline != wordcraft_doc::props::Underline::None, "{rc:?}");
|
||||
assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE));
|
||||
}
|
||||
// It saves: the parts come back.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let got = back.styles.get("Parts").unwrap();
|
||||
for part in TablePart::ALL {
|
||||
assert_eq!(got.table_part(part), st.table_part(part), "{}", part.ooxml());
|
||||
}
|
||||
|
||||
// Hostile params fail cleanly and change nothing.
|
||||
for bad in [
|
||||
json!({"style": "Parts", "part": 5}),
|
||||
json!({"style": "Parts", "part": "diagonal"}),
|
||||
json!({"style": "Parts", "parts": "x"}),
|
||||
json!({"style": "Parts", "parts": [{"fill": "FF0000"}]}),
|
||||
json!({"style": "Parts", "firstRow": {"borders": {"edges": "diagonal"}}}),
|
||||
json!({"style": "Parts", "firstRow": {"borders": {"style": "zigzag"}}}),
|
||||
json!({"style": "Parts", "firstRow": {"borders": [1, 2]}}),
|
||||
json!({"style": "Parts", "firstRow": {"cellAlign": 3}}),
|
||||
json!({"style": "Parts", "firstRow": {"cellAlign": "middleish"}}),
|
||||
json!({"style": "Parts", "firstRow": {"align": "up"}}),
|
||||
json!({"style": "Parts", "firstRow": {"underline": "zigzag"}}),
|
||||
json!({"style": "Parts", "firstRow": "bold"}),
|
||||
// Part fields without `part` (#1288).
|
||||
json!({"style": "Parts", "fill": "FF0000"}),
|
||||
json!({"style": "Parts", "bold": true}),
|
||||
json!({"style": "Parts", "borders": false}),
|
||||
json!({"style": "Parts", "firstRow": {"font": 5}}),
|
||||
] {
|
||||
assert!(s.run("table.modifyStyle", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc.styles.get("Parts"), Some(&st));
|
||||
// Huge and odd numbers are clamped.
|
||||
run(&mut s, "table.modifyStyle", json!({"style": "Parts", "firstRow": {"size": 1e30, "borders": {"edges": "top", "width": -5}}}));
|
||||
let head = s.doc.styles.get("Parts").unwrap().table_part(TablePart::FirstRow);
|
||||
assert_eq!((head.chr.size, head.borders.and_then(|b| b.top).map(|b| b.width)), (Some(1638.0), Some(0.25)));
|
||||
// A null or empty font makes the part's font inherited again (#1288).
|
||||
assert_eq!(head.chr.font.as_deref(), Some("Liberation Serif"));
|
||||
run(&mut s, "table.modifyStyle", json!({"style": "Parts", "firstRow": {"font": null}}));
|
||||
assert_eq!(s.doc.styles.get("Parts").unwrap().table_part(TablePart::FirstRow).chr.font, None);
|
||||
run(&mut s, "table.modifyStyle", json!({"style": "Parts", "part": "lastCol", "font": "Liberation Mono"}));
|
||||
run(&mut s, "table.modifyStyle", json!({"style": "Parts", "part": "lastCol", "font": " "}));
|
||||
assert_eq!(s.doc.styles.get("Parts").unwrap().table_part(TablePart::LastCol).chr.font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_and_table_gridlines_toggle_independently() {
|
||||
// #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell
|
||||
@@ -4991,6 +5524,42 @@ fn portuguese_proofing_through_the_review_commands() {
|
||||
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
|
||||
}
|
||||
|
||||
/// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary.
|
||||
#[test]
|
||||
fn czech_proofing_through_the_review_commands() {
|
||||
czech_test_dict::install();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Město pocitac, škola škola."}));
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "cs_cz", "default": true}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("cs-CZ"));
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "cs-CZ");
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let arr = issues.as_array().unwrap();
|
||||
assert_eq!(arr.len(), 2, "{issues}");
|
||||
assert!(arr.iter().all(|i| i["lang"] == "cs"), "{issues}");
|
||||
assert!(
|
||||
arr.iter().any(|i| i["text"] == "pocitac" && i["kind"] == "spelling" && i["suggestions"].as_array().unwrap().iter().any(|s| s == "počítač")),
|
||||
"{issues}"
|
||||
);
|
||||
assert!(arr.iter().any(|i| i["text"] == "škola škola" && i["kind"] == "grammar" && i["message"] == "Opakované slovo: „škola“"), "{issues}");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "pocitac");
|
||||
run(&mut s, "review.applySuggestion", json!({"text": "počítač"}));
|
||||
assert!(text(&s).starts_with("Město počítač,"));
|
||||
// Exercise dictionary addition on a misspelling still present in the document.
|
||||
run(&mut s, "select.text", json!({"text": "počítač"}));
|
||||
run(&mut s, "text.insert", json!({"text": "zqcsenginevlastni"}));
|
||||
assert_eq!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len(), 2);
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "zqcsenginevlastni"}));
|
||||
let left = run(&mut s, "review.issues", json!({}));
|
||||
let left = left.as_array().unwrap();
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!(left[0]["kind"], "grammar");
|
||||
assert_eq!(left[0]["lang"], "cs");
|
||||
}
|
||||
|
||||
/// Review › Language and the proofing commands work in Polish end to end: the language makes
|
||||
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
|
||||
/// suggestions come from it, and a suggestion fixes the text.
|
||||
@@ -5499,6 +6068,71 @@ fn format_font_sets_the_asian_and_latin_fonts_separately() {
|
||||
assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err());
|
||||
}
|
||||
|
||||
/// The result of the formula field in cell `c` of row `r` of the first table, if there is one.
|
||||
fn formula_result(s: &Session, r: usize, c: usize) -> Option<String> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table())?;
|
||||
t.rows.get(r)?.cells.get(c)?.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Field { instr, result, .. } if instr.starts_with('=') => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A 4 × 4 matrix (values 1 to 9 under the header) with an empty fifth row; the caret in its
|
||||
/// second cell.
|
||||
fn formula_table() -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "table.quick", json!({"kind": "matrix"}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 3, 1, 0], "off": 0}}));
|
||||
run(&mut s, "table.insertRowBelow", json!({}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 4, 1, 0], "off": 0}}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_formula_reads_cell_references_and_functions() {
|
||||
let mut s = formula_table();
|
||||
let r = run(&mut s, "table.formula", json!({"formula": "=B2+B3*2"}));
|
||||
assert_eq!(r["result"], "9");
|
||||
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("9"));
|
||||
let mut s = formula_table();
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=IF(SUM(ABOVE)>10, 1, 0)"}))["result"], "1");
|
||||
let mut s = formula_table();
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(B2:C3) \\# \"0.00\""}))["result"], "12.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_formula_with_an_unknown_function_is_an_error_and_inserts_nothing() {
|
||||
let mut s = formula_table();
|
||||
let err = s.run("table.formula", &json!({"formula": "=MEDIAN(ABOVE)"})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err.contains("MEDIAN"), "{err}");
|
||||
assert_eq!(formula_result(&s, 4, 1), None);
|
||||
assert!(s.run("table.formula", &json!({"formula": "=SUM()"})).is_err(), "empty parentheses are a syntax error, as in Word");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_formula_bare_function_name_takes_its_direction_from_the_cell() {
|
||||
let mut s = formula_table();
|
||||
let r = run(&mut s, "table.formula", json!({"formula": "=SUM"}));
|
||||
assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some("=SUM(ABOVE)"), Some("12")));
|
||||
}
|
||||
|
||||
/// Update Field (F9) works a formula out again from the cells it reads.
|
||||
#[test]
|
||||
fn update_fields_recalculates_a_table_formula() {
|
||||
let mut s = formula_table();
|
||||
run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}));
|
||||
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("12"));
|
||||
// Row 1's 1 becomes 10.
|
||||
run(
|
||||
&mut s,
|
||||
"select.range",
|
||||
json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}),
|
||||
);
|
||||
run(&mut s, "text.insert", json!({"text": "10"}));
|
||||
run(&mut s, "references.updateFields", json!({}));
|
||||
assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("21"));
|
||||
}
|
||||
|
||||
/// A one-column table (no header row) holding `cells`, in a document whose default language
|
||||
/// is `lang`; the caret ends in the last cell.
|
||||
fn column_table(lang: &str, cells: &[&str]) -> Session {
|
||||
@@ -5807,6 +6441,56 @@ fn insert_picture_takes_a_windows_metafile() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Change Picture must keep the replacement's own format: the part's name and content type
|
||||
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
|
||||
/// and LibreOffice then show a broken picture).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn picture_change_keeps_the_replacement_format() {
|
||||
let dir = scratch_dir("picture-change-format");
|
||||
let first = dir.join("first.png");
|
||||
std::fs::write(&first, tiny_png()).unwrap();
|
||||
let mut jpeg = Vec::new();
|
||||
image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
|
||||
let second = dir.join("second.jpg");
|
||||
std::fs::write(&second, &jpeg).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
|
||||
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
|
||||
let mut media = String::new();
|
||||
s.doc.objects_in_reading_order(&mut |o| {
|
||||
if let wordcraft_doc::para::InlineObject::Image { media: m, .. } = o {
|
||||
media = m.clone();
|
||||
}
|
||||
});
|
||||
assert!(media.ends_with(".jpeg") || media.ends_with(".jpg"), "the JPEG keeps its format: {media}");
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.media.get(&media).map(|b| b.as_slice()), Some(jpeg.as_slice()), "the saved part is the JPEG");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_open_repairs_hostile_run_lengths() {
|
||||
use wordcraft_doc::{Block, Document};
|
||||
// "héllo": é is two bytes (1..3); a run ending at 2 splits it, usize::MAX overflows a sum.
|
||||
for lens in [vec![2, 4], vec![usize::MAX, usize::MAX], vec![1], vec![], vec![0, 99]] {
|
||||
let mut d = Document::from_text("héllo");
|
||||
if let Some(Block::Para(p)) = d.body.first_mut().map(std::sync::Arc::make_mut) {
|
||||
p.runs = lens.iter().map(|&len| wordcraft_doc::para::Run { len, props: Default::default() }).collect();
|
||||
}
|
||||
let bytes = serde_json::to_vec(&d).unwrap();
|
||||
let (doc, _) = crate::io::open_bytes_with("x.json", &bytes, None).unwrap();
|
||||
let Some(Block::Para(p)) = doc.body.first().map(|b| &**b) else { panic!("no paragraph") };
|
||||
let mut end = 0;
|
||||
for (r, _) in p.run_ranges() {
|
||||
assert!(p.text.is_char_boundary(r.end) && r.end > r.start, "{lens:?}");
|
||||
end = r.end;
|
||||
}
|
||||
assert_eq!(end, p.text.len(), "{lens:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_aliases_add_one_undo_step() {
|
||||
let mut s = s();
|
||||
@@ -6411,6 +7095,22 @@ fn exported_page_images_draw_headers_at_full_strength() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Text typed right after a footnote or endnote number is plain body text, not another raised number.
|
||||
#[test]
|
||||
fn typing_after_a_note_number_is_not_superscript() {
|
||||
for (cmd_id, style) in [("references.footnote", "FootnoteReference"), ("references.endnote", "EndnoteReference")] {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "One"}));
|
||||
run(&mut s, cmd_id, json!({"text": "Note"}));
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).and_then(|p| p.props_at(p.len()).style.clone()).as_deref(), Some(style));
|
||||
assert_eq!(s.typing_props().style, None, "{cmd_id}");
|
||||
run(&mut s, "text.insert", json!({"text": " after"}));
|
||||
let end = s.doc.end_of(StoryRef::Body);
|
||||
assert_eq!(s.doc.para_at(&end).map(|p| p.props_of_char(p.len() - 1).style.clone()), Some(None), "{cmd_id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Gap 19, review: text inserted by one author and deleted by another keeps both marks through
|
||||
/// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it.
|
||||
#[test]
|
||||
@@ -6496,3 +7196,39 @@ fn odt_deleted_mark_inside_an_insertion_survives_reject_all() {
|
||||
assert_eq!(text(&s), "One \n two");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1320: `layout.verticalAlign` sets the section's vertical alignment, Justified included
|
||||
/// ("justify", or "both"/"justified"); a value it doesn't know is Top, as before. Section JSON
|
||||
/// for `layout.pageSetup` keeps its names.
|
||||
#[test]
|
||||
fn vertical_align_command_sets_justified() {
|
||||
use wordcraft_doc::section::PageVAlign;
|
||||
let mut s = s();
|
||||
let va = |s: &Session| crate::cmd::page::sect(s).valign;
|
||||
for (value, want) in [
|
||||
("justify", PageVAlign::Justify),
|
||||
("center", PageVAlign::Center),
|
||||
("both", PageVAlign::Justify),
|
||||
("bottom", PageVAlign::Bottom),
|
||||
("justified", PageVAlign::Justify),
|
||||
("top", PageVAlign::Top),
|
||||
("sideways", PageVAlign::Top),
|
||||
] {
|
||||
run(&mut s, "layout.verticalAlign", json!({"value": value}));
|
||||
assert_eq!(va(&s), want, "{value}");
|
||||
}
|
||||
run(&mut s, "layout.verticalAlign", json!({"value": "justify"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(va(&s), PageVAlign::Top, "one Undo step");
|
||||
let spec = s.registry.get("layout.verticalAlign").unwrap();
|
||||
assert!(spec.params.contains("justify"), "documents its values: {}", spec.params);
|
||||
// Section JSON: today's names still read, and Justified is "justify".
|
||||
let mut props = serde_json::to_value(crate::cmd::page::sect(&s)).unwrap();
|
||||
assert_eq!(props["valign"], json!("top"));
|
||||
for (name, want) in [("center", PageVAlign::Center), ("bottom", PageVAlign::Bottom), ("justify", PageVAlign::Justify), ("top", PageVAlign::Top)] {
|
||||
props["valign"] = json!(name);
|
||||
run(&mut s, "layout.pageSetup", json!({"section": props.clone()}));
|
||||
assert_eq!(va(&s), want, "{name}");
|
||||
assert_eq!(serde_json::to_value(crate::cmd::page::sect(&s)).unwrap()["valign"], json!(name));
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,10 @@ fn bad_font_input_is_an_error() {
|
||||
let r = s.run("font.add", &json!({})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont"));
|
||||
|
||||
let r = s.run("font.loadSystem", &json!({})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert!(s.ui_requests.iter().any(|r| r["open"] == "loadSystemFonts"));
|
||||
}
|
||||
|
||||
/// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an
|
||||
|
||||
@@ -52,7 +52,17 @@ fn typed(keys: &str) -> Session {
|
||||
#[test]
|
||||
fn sample_text_formulas_expand_on_enter() {
|
||||
for name in ["rand", "lorem"] {
|
||||
for (args, paragraphs, sentences) in [("", 5, 3), ("2", 2, 3), ("2,4", 2, 4), ("1,1", 1, 1), ("200,1", 200, 1), ("1,200", 1, 200)] {
|
||||
for (args, paragraphs, sentences) in [
|
||||
("", 5, 3),
|
||||
("2", 2, 3),
|
||||
(" 2 ", 2, 3),
|
||||
("2,4", 2, 4),
|
||||
("2, 4", 2, 4),
|
||||
(" 2 , 4 ", 2, 4),
|
||||
("1,1", 1, 1),
|
||||
("200,1", 200, 1),
|
||||
("1,200", 1, 200),
|
||||
] {
|
||||
let mut s = typed(&format!("={name}({args})"));
|
||||
let depth = s.undo_depth();
|
||||
typ(&mut s, "\n");
|
||||
@@ -357,8 +367,8 @@ fn web_addresses_become_links() {
|
||||
|
||||
#[test]
|
||||
fn dashes() {
|
||||
let s = typed("a -- b and c - d and e--f ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f ");
|
||||
let s = typed("a -- b and c - d and e--f and café--g ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f and café—g ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1115,7 +1125,7 @@ fn typing_after_a_citation_goes_outside_its_field() {
|
||||
let c = CharProps::default();
|
||||
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let start = p.len();
|
||||
p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false }, &c).unwrap();
|
||||
p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap();
|
||||
let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap();
|
||||
p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap();
|
||||
let mut s = Session::new(doc);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Unlocked `DATE` and `TIME` fields are brought up to now when a document is opened, as Word
|
||||
//! does whenever it shows, prints or exports one (#1672). Packages are hand-written: the writer
|
||||
//! would never produce the stale cached result this is about.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{InlineObject, StoryRef};
|
||||
|
||||
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A field run: its code, its cached result, and whether it carries `w:fldLock`.
|
||||
fn field(instr: &str, cached: &str, locked: bool) -> String {
|
||||
let lock = if locked { r#" w:fldLock="1""# } else { "" };
|
||||
format!(
|
||||
concat!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"{}/></w:r>"#,
|
||||
r#"<w:r><w:instrText xml:space="preserve"> {} </w:instrText></w:r>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
|
||||
r#"<w:r><w:t xml:space="preserve">{}</w:t></w:r>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
||||
),
|
||||
lock,
|
||||
instr.replace('"', """),
|
||||
cached
|
||||
)
|
||||
}
|
||||
|
||||
fn open_body(body: &str) -> wordcraft_doc::Document {
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
wordcraft_engine::io::open_bytes("d.docx", &zip(&[("_rels/.rels", rels), ("word/document.xml", &doc)])).expect("opens")
|
||||
}
|
||||
|
||||
/// Every field of the body, as (code, result).
|
||||
fn fields(d: &wordcraft_doc::Document) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for path in d.para_paths(StoryRef::Body) {
|
||||
let Some(p) = d.para(StoryRef::Body, &path) else { continue };
|
||||
for o in &p.objects {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
out.push((instr.clone(), result.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocked_date_and_time_fields_are_refreshed_on_open() {
|
||||
// The reported fixture: both cached in January 2020.
|
||||
let d = open_body(&format!(
|
||||
"<w:p>{}</w:p><w:p>{}</w:p>",
|
||||
field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", false),
|
||||
field(r#"TIME \@ "dddd, d MMMM yyyy""#, "Wednesday, 1 January 2020", false),
|
||||
));
|
||||
let got = fields(&d);
|
||||
assert_eq!(got.len(), 2);
|
||||
// Compared against the same clock the refresh used, so the test does not age.
|
||||
assert_eq!(got[0].1, wordcraft_engine::cmd::insert::format_date("yyyy-MM-dd"), "DATE is today");
|
||||
assert_eq!(got[1].1, wordcraft_engine::cmd::insert::format_date("dddd, d MMMM yyyy"), "TIME is today");
|
||||
assert_ne!(got[0].1, "2020-01-01", "not the stored result");
|
||||
// The code itself is untouched, so a save writes the field back as it was.
|
||||
assert_eq!(got[0].0, r#"DATE \@ "yyyy-MM-dd""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_field_with_no_picture_uses_words_default() {
|
||||
let d = open_body(&format!("<w:p>{}</w:p><w:p>{}</w:p>", field("DATE", "2020-01-01", false), field("TIME", "00:00", false)));
|
||||
let got = fields(&d);
|
||||
assert_eq!(got[0].1, wordcraft_engine::cmd::insert::format_date("M/d/yyyy"));
|
||||
assert_eq!(got[1].1, wordcraft_engine::cmd::insert::format_date("h:mm am/pm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_date_field_keeps_its_stored_result() {
|
||||
// `w:fldLock` is exactly the instruction not to refresh.
|
||||
let d = open_body(&format!("<w:p>{}</w:p>", field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", true)));
|
||||
assert_eq!(fields(&d)[0].1, "2020-01-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_date_fields_keep_their_stored_result() {
|
||||
// These record when something happened; they are not "now", and Word does not refresh them
|
||||
// on open either. Nor is any other field touched.
|
||||
let d = open_body(&format!(
|
||||
"<w:p>{}{}{}{}</w:p>",
|
||||
field(r#"CREATEDATE \@ "yyyy-MM-dd""#, "2019-05-05", false),
|
||||
field(r#"SAVEDATE \@ "yyyy-MM-dd""#, "2019-06-06", false),
|
||||
field(r#"PRINTDATE \@ "yyyy-MM-dd""#, "2019-07-07", false),
|
||||
field("AUTHOR", "Ada Lovelace", false),
|
||||
));
|
||||
let results: Vec<String> = fields(&d).into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!(results, ["2019-05-05", "2019-06-06", "2019-07-07", "Ada Lovelace"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_field_in_a_header_is_refreshed_too() {
|
||||
// A letter template usually carries its date in the header, which is its own story.
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let drels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rH1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#;
|
||||
let header = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p>{}</w:p></w:hdr>"#,
|
||||
field(r#"DATE \@ "yyyy-MM-dd""#, "2020-01-01", false)
|
||||
);
|
||||
let doc = r#"<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/></w:sectPr></w:body></w:document>"#;
|
||||
let d = wordcraft_engine::io::open_bytes(
|
||||
"d.docx",
|
||||
&zip(&[("_rels/.rels", rels), ("word/document.xml", doc), ("word/_rels/document.xml.rels", drels), ("word/header1.xml", &header)]),
|
||||
)
|
||||
.expect("opens");
|
||||
let today = wordcraft_engine::cmd::insert::format_date("yyyy-MM-dd");
|
||||
let found: Vec<String> = d
|
||||
.parts
|
||||
.values()
|
||||
.flat_map(|p| p.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.clone()))
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(found, [today], "the header's field is refreshed as well as the body's");
|
||||
}
|
||||
@@ -890,11 +890,13 @@ fn style_italic(style: &str) -> bool {
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first,
|
||||
/// so they are the fallback for Japanese text after the requested and bundled fonts), then the
|
||||
/// craft-fonts Simplified Chinese faces, for the ideographs the Japanese fonts lack (both empty
|
||||
/// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||
/// other craft-fonts faces: Simplified Chinese, for the ideographs the Japanese fonts lack, and
|
||||
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
|
||||
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
let craft = crate::japanese_document_fonts().into_iter().chain(crate::chinese_fonts()).map(|f| f.bytes);
|
||||
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
|
||||
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for f in enumerate_faces(data) {
|
||||
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
@@ -1529,6 +1531,7 @@ impl FontDb {
|
||||
}
|
||||
|
||||
fn search_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
|
||||
let mut base = self.loaded_covering(chars, exclude);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if base.is_none() {
|
||||
|
||||
+39
-6
@@ -24,7 +24,7 @@ pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
@@ -65,15 +65,32 @@ pub fn chinese_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Hans"))
|
||||
}
|
||||
|
||||
/// The CJK interface faces, Regular styles only (the UI fakes no bold): the Japanese UI face and
|
||||
/// the Chinese face, Chinese first when `prefer_hans` (a Chinese interface), Japanese first
|
||||
/// otherwise. Mixing the two within a line would mix glyph styles, so the first face should
|
||||
/// cover the interface language. Empty without craft-fonts.
|
||||
/// The craft-fonts faces for Korean (`"Kore"`), in manifest order. Empty without craft-fonts.
|
||||
pub fn korean_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Kore"))
|
||||
}
|
||||
|
||||
/// The CJK interface faces, Regular styles only (the UI fakes no bold), with the Japanese UI,
|
||||
/// Chinese, and Korean faces ordered for the selected interface language. Mixing the faces within
|
||||
/// a line would mix glyph styles, so the first face should cover the interface language. Empty
|
||||
/// without craft-fonts.
|
||||
pub fn ui_cjk_fonts(prefer_hans: bool) -> Vec<&'static CraftFont> {
|
||||
ui_cjk_fonts_for(prefer_hans, false)
|
||||
}
|
||||
|
||||
/// The CJK interface faces in the preferred writing system's order.
|
||||
pub fn ui_cjk_fonts_for(prefer_hans: bool, prefer_korean: bool) -> Vec<&'static CraftFont> {
|
||||
let regular = |f: &&CraftFont| f.style == "Regular";
|
||||
let ja = japanese_ui_fonts(false).into_iter().filter(regular).take(1);
|
||||
let zh = chinese_fonts().filter(regular).take(1);
|
||||
if prefer_hans { zh.chain(ja).collect() } else { ja.chain(zh).collect() }
|
||||
let ko = korean_fonts().filter(regular).take(1);
|
||||
if prefer_korean {
|
||||
ko.chain(ja).chain(zh).collect()
|
||||
} else if prefer_hans {
|
||||
zh.chain(ja).chain(ko).collect()
|
||||
} else {
|
||||
ja.chain(zh).chain(ko).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled serif used when a document asks for nothing better.
|
||||
@@ -385,6 +402,22 @@ mod tests {
|
||||
assert!(f2.family.contains("Mincho"), "{f2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_and_persian_fall_back_to_the_craft_fonts_arabic_face() {
|
||||
// #1652: with no system fonts (a headless CLI), Arabic and Persian drew as empty boxes.
|
||||
let Some(arabic) = CRAFT_FONTS.iter().find(|f| f.scripts.contains(&"Arab")) else {
|
||||
eprintln!("skipped: no Arabic face in the optional craft-fonts input");
|
||||
return;
|
||||
};
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
for c in "مرحبا پچژگ۱۲۳".chars().filter(|c| !c.is_whitespace()) {
|
||||
let fb = db.fallback_for(c, latin.id()).unwrap_or_else(|| panic!("no fallback for {c}"));
|
||||
assert_eq!(fb.family, arabic.family, "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_without_craft_fonts() {
|
||||
// Always true without CRAFT_FONTS_DIR; with it, the bundled fonts must still all load.
|
||||
|
||||
@@ -70,16 +70,25 @@ const MAX_NEEDED: usize = 4096;
|
||||
/// as blank space.
|
||||
const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
/// A character only a font for the language has: a simplified-only hanzi, or kana.
|
||||
fn probe(hans: bool) -> char {
|
||||
if hans { '页' } else { 'の' }
|
||||
/// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul.
|
||||
fn probe(hans: bool, korean: bool) -> char {
|
||||
if korean {
|
||||
'한'
|
||||
} else if hans {
|
||||
'页'
|
||||
} else {
|
||||
'の'
|
||||
}
|
||||
}
|
||||
|
||||
/// Font files to look for, relative to a font folder (or absolute), best first. Chinese faces
|
||||
/// cover kana too, so the Japanese list ends with the Chinese one.
|
||||
fn candidates(hans: bool) -> Vec<&'static str> {
|
||||
let (zh, ja): (&[&str], &[&str]) = if cfg!(windows) {
|
||||
(&["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"], &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"])
|
||||
/// Font files to look for, relative to a font folder (or absolute), best first.
|
||||
fn candidates(hans: bool, korean: bool) -> Vec<&'static str> {
|
||||
let (zh, ja, ko): (&[&str], &[&str], &[&str]) = if cfg!(windows) {
|
||||
(
|
||||
&["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"],
|
||||
&["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"],
|
||||
&["malgun.ttf", "malgunsl.ttf", "gulim.ttc", "batang.ttc"],
|
||||
)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
(
|
||||
&[
|
||||
@@ -92,6 +101,7 @@ fn candidates(hans: bool) -> Vec<&'static str> {
|
||||
"/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc",
|
||||
],
|
||||
&["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"],
|
||||
&["/System/Library/Fonts/AppleSDGothicNeo.ttc", "/System/Library/Fonts/Supplemental/AppleGothic.ttf"],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -110,18 +120,37 @@ fn candidates(hans: bool) -> Vec<&'static str> {
|
||||
"truetype/droid/DroidSansFallbackFull.ttf",
|
||||
],
|
||||
&["noto-cjk/NotoSansCJKjp-Regular.otf", "opentype/noto/NotoSansCJKjp-Regular.otf"],
|
||||
&[
|
||||
"noto-cjk/NotoSansCJKkr-Regular.otf",
|
||||
"opentype/noto/NotoSansCJKkr-Regular.otf",
|
||||
"google-noto-sans-cjk-fonts/NotoSansCJKkr-Regular.otf",
|
||||
"adobe-source-han-sans/SourceHanSansK-Regular.otf",
|
||||
"opentype/adobe-source-han-sans/SourceHanSansK-Regular.otf",
|
||||
"truetype/nanum/NanumGothic.ttf",
|
||||
],
|
||||
)
|
||||
};
|
||||
if hans { zh.to_vec() } else { ja.iter().chain(zh).copied().collect() }
|
||||
if korean {
|
||||
ko.iter().chain(ja).chain(zh).copied().collect()
|
||||
} else if hans {
|
||||
zh.to_vec()
|
||||
} else {
|
||||
ja.iter().chain(zh).copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed`
|
||||
/// (the characters the embedded faces lack; empty: a character typical of the language), if one
|
||||
/// is found. Never panics on damaged files.
|
||||
pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||||
let probe = [probe(hans)];
|
||||
system_cjk_ui_font_for(hans, false, needed)
|
||||
}
|
||||
|
||||
/// The installed CJK font for a Chinese, Korean or Japanese interface.
|
||||
pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||||
let probe = [probe(hans, korean)];
|
||||
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans), needed, hans)
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, hans)
|
||||
}
|
||||
|
||||
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
|
||||
@@ -210,6 +239,22 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn korean_interface_uses_korean_first_system_font_candidates() {
|
||||
let names = candidates(false, true);
|
||||
assert_eq!(
|
||||
names.first(),
|
||||
Some(&if cfg!(windows) {
|
||||
"malgun.ttf"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"/System/Library/Fonts/AppleSDGothicNeo.ttc"
|
||||
} else {
|
||||
"noto-cjk/NotoSansCJKkr-Regular.otf"
|
||||
})
|
||||
);
|
||||
assert_eq!(probe(false, true), '한');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() {
|
||||
// A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486).
|
||||
|
||||
@@ -233,21 +233,27 @@ impl Lexer<'_> {
|
||||
return Some(Tok::Text(String::new()));
|
||||
}
|
||||
if rest.starts_with("</") {
|
||||
let name: String =
|
||||
rest.strip_prefix("</").unwrap_or("").chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').collect();
|
||||
let name: String = rest
|
||||
.strip_prefix("</")
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-')
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
if name.is_empty() {
|
||||
self.pos += 2;
|
||||
return Some(Tok::Text("</".into()));
|
||||
}
|
||||
self.skip_past(">");
|
||||
return Some(Tok::End(name.to_ascii_lowercase()));
|
||||
return Some(Tok::End(name));
|
||||
}
|
||||
if !b.get(1).is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
self.pos += 1;
|
||||
return Some(Tok::Text("<".into()));
|
||||
}
|
||||
// Start tag.
|
||||
let name: String = rest[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').collect();
|
||||
let name: String =
|
||||
rest[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').map(|c| c.to_ascii_lowercase()).collect();
|
||||
let mut i = 1 + name.len();
|
||||
let mut attrs = Vec::new();
|
||||
let mut self_close = false;
|
||||
@@ -311,7 +317,7 @@ impl Lexer<'_> {
|
||||
}
|
||||
}
|
||||
self.pos += i.min(rest.len());
|
||||
Some(Tok::Start { name: name.to_ascii_lowercase(), attrs, self_close })
|
||||
Some(Tok::Start { name, attrs, self_close })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1071,11 +1071,13 @@ pub(crate) fn escape(s: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Escape what would start a block at the beginning of a line.
|
||||
/// Escape what would start a block at the beginning of a line. Leading spaces are kept, but at
|
||||
/// most three (a tab counts as four): four would turn the line into an indented code block.
|
||||
fn escape_line_start(s: String) -> String {
|
||||
let s = s.trim_start().to_string();
|
||||
let t = s.as_str();
|
||||
let lead = s.len() - t.len();
|
||||
let t = s.trim_start_matches([' ', '\t']);
|
||||
let lead = s.get(..s.len() - t.len()).unwrap_or("");
|
||||
let cols: usize = lead.chars().map(|c| if c == '\t' { 4 } else { 1 }).sum();
|
||||
let indent = " ".repeat(cols.min(3));
|
||||
let needs = t.starts_with('#')
|
||||
|| t.starts_with('>')
|
||||
|| t.starts_with("- ")
|
||||
@@ -1089,12 +1091,12 @@ fn escape_line_start(s: String) -> String {
|
||||
d > 0 && t.get(d..).is_some_and(|r| r.starts_with(". ") || r.starts_with(") ") || r == "." || r == ")")
|
||||
};
|
||||
if !needs {
|
||||
return s;
|
||||
return if cols > 3 || lead.contains('\t') { format!("{indent}{t}") } else { s };
|
||||
}
|
||||
if let Some(d) = t.find(['.', ')']).filter(|_| t.starts_with(|c: char| c.is_ascii_digit())) {
|
||||
return format!("{}{}\\{}", &s[..lead], &t[..d], &t[d..]);
|
||||
return format!("{indent}{}\\{}", &t[..d], &t[d..]);
|
||||
}
|
||||
format!("{}\\{}", &s[..lead], t)
|
||||
format!("{indent}\\{t}")
|
||||
}
|
||||
|
||||
fn code_span(t: &str) -> String {
|
||||
@@ -1476,6 +1478,19 @@ mod tests {
|
||||
assert!(q.list.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_line_start_preserves_indentation() {
|
||||
assert_eq!(escape_line_start(" indented text".to_string()), " indented text");
|
||||
assert_eq!(escape_line_start(" # not a heading".to_string()), " \\# not a heading");
|
||||
assert_eq!(escape_line_start(" 1. not a list".to_string()), " 1\\. not a list");
|
||||
assert_eq!(escape_line_start(" > quote".to_string()), " \\> quote");
|
||||
// Four spaces or a tab would make an indented code block: the indent is capped at three.
|
||||
assert_eq!(escape_line_start(" deep".to_string()), " deep");
|
||||
assert_eq!(escape_line_start("\tTabbed # text".to_string()), " Tabbed # text");
|
||||
assert_eq!(escape_line_start(" # not a heading".to_string()), " \\# not a heading");
|
||||
assert_eq!(escape_line_start(" \t1. not a list".to_string()), " 1\\. not a list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_backtick_code_span_escaped_pipe() {
|
||||
let md = "| Command | Status |\n| --- | --- |\n| `cmd \\| grep` | pass |\n";
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! Quotations, Preformatted Text, page layout) and `meta.xml`.
|
||||
//!
|
||||
//! Import reads the same subset back from `content.xml` / `styles.xml` (style inheritance
|
||||
//! resolved, both automatic and common styles), skipping notes, annotations and text boxes.
|
||||
//! resolved, both automatic and common styles), skipping notes, annotations, text boxes and drawing shapes.
|
||||
//!
|
||||
//! Tracked changes (ODF 1.3 Part 3 §5.5) go both ways: `text:tracked-changes` holds one
|
||||
//! `text:changed-region` per change; an insertion is the body text between
|
||||
@@ -1642,6 +1642,14 @@ impl Body<'_> {
|
||||
| "table-of-content-source" => {
|
||||
self.skip = 1;
|
||||
}
|
||||
// Drawing shapes (ODF 1.3 §10.3), with their text, until shapes are read (#1654).
|
||||
// Groups are walked, so the pictures in them still are.
|
||||
"rect" | "line" | "polyline" | "polygon" | "regular-polygon" | "path" | "circle" | "ellipse" | "connector" | "caption" | "measure"
|
||||
| "custom-shape"
|
||||
if q.starts_with("draw:") =>
|
||||
{
|
||||
self.skip = 1;
|
||||
}
|
||||
"p" | "h" if !self.in_desc => {
|
||||
let h = if local(q) == "h" { Some(get(a, "text:outline-level").and_then(|v| v.parse::<u8>().ok()).unwrap_or(1)) } else { None };
|
||||
self.start_para(get(a, "text:style-name"), h);
|
||||
@@ -2481,6 +2489,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_after_a_drawing_shape_stays_in_its_paragraph() {
|
||||
// #1654: a shape's own text:p closed the paragraph it is anchored in, and the text after
|
||||
// the shape was dropped. Shapes are skipped like text boxes; the paragraph keeps its text.
|
||||
let draw =
|
||||
r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {draw}><office:body><office:text><text:p text:style-name="Heading"><draw:custom-shape svg:width="20mm" svg:height="20mm"><text:p>In the shape</text:p><draw:enhanced-geometry draw:type="rectangle"/></draw:custom-shape>Before <draw:rect><text:p/></draw:rect>middle <draw:g><draw:ellipse><text:p>In the group</text:p></draw:ellipse></draw:g>after</text:p><text:p>Next</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading" style:family="paragraph"><style:text-properties fo:font-weight="bold"/></style:style></office:styles></office:document-styles>"#
|
||||
);
|
||||
let f = parse(&package(&content, Some(&styles))).unwrap();
|
||||
let ps = paras(&f);
|
||||
assert_eq!(ps.iter().map(|p| p.text()).collect::<Vec<_>>(), ["Before middle after", "Next"]);
|
||||
assert!(ps[0].inlines.iter().all(|i| !matches!(i, Inline::Text(_, f) if !f.bold)), "{:?}", ps[0].inlines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_orientation_and_margins_round_trip() {
|
||||
// #810: the page setup written on export is read back on import.
|
||||
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user