diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0959ec140..2951acfc3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,3 +46,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/Cargo.lock b/Cargo.lock index 8eae304e33..666b2a033f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1103,7 +1103,7 @@ dependencies = [ [[package]] name = "craft-proof" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=89fa1559e94378ecef85cd7e4681400f4df053e0#89fa1559e94378ecef85cd7e4681400f4df053e0" +source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45" dependencies = [ "log", "miniz_oxide 0.9.1", @@ -1520,6 +1520,19 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "profiling", +] + [[package]] name = "egui_glow" version = "0.37.0" @@ -1587,6 +1600,26 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-map" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2927,7 +2960,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "base64 0.22.1", "bumpalo", @@ -6752,7 +6785,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.14.0" +version = "0.15.1" dependencies = [ "arboard", "chrono", @@ -6787,7 +6820,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.14.0" +version = "0.15.1" dependencies = [ "serde_json", "sys-locale", @@ -6800,11 +6833,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.14.0" +version = "0.15.1" [[package]] name = "wordcraft-doc" -version = "0.14.0" +version = "0.15.1" dependencies = [ "base64 0.23.1", "log", @@ -6818,7 +6851,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.14.0" +version = "0.15.1" dependencies = [ "cfb", "chrono", @@ -6835,7 +6868,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.14.0" +version = "0.15.1" dependencies = [ "aes", "base64 0.23.1", @@ -6859,7 +6892,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.14.0" +version = "0.15.1" dependencies = [ "cfb", "chrono", @@ -6892,7 +6925,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.14.0" +version = "0.15.1" dependencies = [ "brotli-decompressor", "harfrust 0.13.3", @@ -6906,26 +6939,34 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.14.0" +version = "0.15.1" dependencies = [ + "aes", + "base64 0.23.1", "cfb", "chardetng", "chrono", "encoding_rs", + "getrandom 0.3.4", + "hmac", "image", "js-sys", "log", + "miniz_oxide 0.9.1", "proptest", "quick-xml 0.41.0", + "sha1 0.11.0", + "sha2 0.11.1", "wordcraft-doc", "wordcraft-geom", "wordcraft-metafile", + "zeroize", "zip 3.0.0", ] [[package]] name = "wordcraft-geom" -version = "0.14.0" +version = "0.15.1" dependencies = [ "image", "serde", @@ -6933,7 +6974,7 @@ dependencies = [ [[package]] name = "wordcraft-layout" -version = "0.14.0" +version = "0.15.1" dependencies = [ "craft-proof", "image", @@ -6953,7 +6994,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.14.0" +version = "0.15.1" dependencies = [ "log", "serde_json", @@ -6965,7 +7006,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.14.0" +version = "0.15.1" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -6973,7 +7014,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.14.0" +version = "0.15.1" dependencies = [ "chrono", "craft-pdf", @@ -6994,7 +7035,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.14.0" +version = "0.15.1" dependencies = [ "image", "log", @@ -7008,8 +7049,9 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.14.0" +version = "0.15.1" dependencies = [ + "accesskit_consumer", "cfb", "chrono", "craft-i18n", @@ -7017,6 +7059,7 @@ dependencies = [ "craft-update", "craft-window", "egui", + "egui_extras", "egui_kittest", "image", "js-sys", @@ -7039,7 +7082,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.14.0" +version = "0.15.1" dependencies = [ "eframe", "egui", @@ -7058,7 +7101,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.14.0" +version = "0.15.1" dependencies = [ "log", "proptest", @@ -7221,7 +7264,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.15.1" dependencies = [ "serde_json", ] @@ -7580,11 +7623,6 @@ name = "egui_demo_lib" version = "0.37.0" source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" -[[patch.unused]] -name = "egui_extras" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - [[patch.unused]] name = "egui_inspection" version = "0.37.0" diff --git a/Cargo.toml b/Cargo.toml index 7fce587029..5e126cf324 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"] [workspace.package] -version = "0.14.0" +version = "0.15.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -24,11 +24,11 @@ wordcraft-pdf = { path = "crates/pdf" } # Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's # language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the # `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged. -# craft-proof is the head of storytold/craft-libs#495 (review fixes), which contains #485 (CP4.2, -# Language packs, on #483 SCOWL English and #482); the font store's `language-pack` kind is #486 -# (CP4.1). GitHub serves PR heads from storytold/craft-libs, and merged with merge commits they -# stay in its main. -wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "89fa1559e94378ecef85cd7e4681400f4df053e0", version = "=0.1.0" } +# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia +# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also +# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's +# `language-pack` kind is #486 (CP4.1). +wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" } craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" } craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" } wordcraft-engine = { path = "crates/engine" } @@ -67,7 +67,7 @@ wuff = "0.2.9" brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } quick-xml = "0.41" @@ -92,6 +92,8 @@ zeroize = "1" # dependency of other crates. libc = "0.2" +# time + # ui egui = "0.37" egui_extras = { version = "0.37", default-features = false } @@ -132,7 +134,7 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000000..e17f1e76d6 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,50 @@ +# WordCraft + +A clean-room, Rust-native word processor. These are the words its interface, docs and code use for +the same things. + +## Languages and proofing + +**Interface language**: +The language of WordCraft's own menus, buttons and messages. +_Avoid_: Display language, UI locale + +**Proofing language**: +The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation +are checked. A language is told apart from another by its tag (language, script, region, variant), +never by the name a list shows for it. +_Avoid_: Text language, editing language, document language + +**Language pack**: +A downloadable bundle of a language's spelling dictionary and hyphenation patterns. +_Avoid_: Dictionary download, language data (that is the embedded data) + +**Language list**: +The proofing languages offered in Set Proofing Language, Review › Language and the status bar's +menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language, +every available) Language pack, each once. +_Avoid_: Language menu (that is only one of the places it is shown) + +**Your languages**: +The proofing languages Options › Language lists: each English variant in use (the default proofing +language and any in the open document), every language with an installed +Language pack, and every proofing language used in the open document. +_Avoid_: Authoring languages, preferred languages + +**Default proofing language**: +The proofing language new blank documents get, set with Set as default; without one, the interface +language. +_Avoid_: Preferred language + +**Proofing status**: +Where a language's proofing comes from: built in, an installed Language pack, your own dictionary, +a system dictionary, available to install, or not available. + +**Custom dictionary**: +The words you added with Add to Dictionary, accepted in every language. +_Avoid_: User dictionary (in the interface) + +**Dictionaries folder**: +A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack +comes after it). +_Avoid_: Custom dictionaries diff --git a/README.md b/README.md index 2db5753ef4..1b1bcc2ede 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,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, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (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, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) | | **Files** | .docx read/write, legacy 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 diff --git a/ROADMAP.md b/ROADMAP.md index f82328f845..029eb8af2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** trivial (pointer zoom no longer re-renders pages every frame); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (pointer zoom no longer re-renders pages every frame); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **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 @@ -108,8 +108,8 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Simplified Chinese | zh-hans | partial | ~95% | | Spanish | es | partial | ~95% | | Hindi | hi | none | 0% | -| Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% | -| French | fr | partial (interface complete, native-speaker reviewed; no proofing) | 100% (2,615 entries) | +| Arabic | ar | partial (catalog + mirrored UI; no kashida; no proofing; native review open) | ~95% | +| French | fr | partial (interface complete, native-speaker reviewed; spelling from the French Language pack, no grammar marks) | 100% (2,615 entries) | | Portuguese | pt-br (pt-PT none) | partial | ~95% | | Indonesian | id | none | 0% | | Japanese | ja | partial | ~95% | @@ -139,6 +139,62 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-04: OpenDocument files have no display settings any more (gap 19): File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values are gone. WordCraft makes one choice for each case the standard leaves open, the one the default made, and still follows what a file states ([docs/odf-interpretation.md](docs/odf-interpretation.md)). +- 2026-10-31: Dialogs are modal, as in Word (#3859): while one is open, the ribbon, File, the page and the panes of its document window take no clicks, and typing and shortcuts (Ctrl+O…) do nothing until it closes; WordCraft's own Open/Save As window is modal too. A press beside a modal dialog shakes it a little, so it is clear why nothing behind it reacts (Word beeps); with animation effects off, its border flashes instead. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac 16.114.1 (checked on a newer build than the 16.113.4 the parity target names). +- 2026-11-04: OpenDocument files are shown in one of two styles (gap 19): Microsoft Word style, the default, or LibreOffice style (File › Options › Advanced › OpenDocument files; `file.options` `{"odf": {"preset": "word"}}` or `"libreoffice"`; `WORDCRAFT_ODF=word` or `WORDCRAFT_ODF=libreoffice`). Custom stays for one's own value per case; `wordcraft`, the earlier id of Word's style, is still taken. +- 2026-11-04: ODT element audit (gap 19, [docs/odt-element-audit.md](docs/odt-element-audit.md)): every element of the ODF 1.3 schema checked against 365 files and a document of our own for each one no file held that a text document can hold (the `presentation:`, `anim:` and `db:` elements only presentations and databases hold stay unseen); all open and save, and a save without edits keeps them (password, number, time and grid form controls included). Over the 220 files from other applications, 177 round-trip pixel-identical, 201 with the same text and 218 with the same page count (the latest measurement, after the groups and form-control work). +- 2026-11-04: ODT checked against 220 files from other applications (Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony; local only): every file LibreOffice opens opens again after a WordCraft save (a manifest entry was missing), 166 round-trip pixel-identical and 194 with the same text at the time (an earlier measurement; the element-audit entry above has the current figures); hidden sections stay hidden and unedited lists are written as read (gap 19). +- 2026-11-03: ODT paragraphs holding bookmarks, notes, formulas and objects, and the page-anchored frames before a paragraph, are written as read while unchanged (gap 19); styles of different families sharing a name are kept apart. Over the corpus 123 of 133 files round-trip pixel-identical (118), 132 with the same text, and the attributes and values a save loses fall from 539 to under 315. +- 2026-11-03: ODT paragraphs showing text boxes and shapes holding text are written as read while that text is unchanged (gap 19). Over the corpus 111 of 133 files now round-trip pixel-identical (103), the round-trip difference falls from 44k to 39k pixels, and the element kinds a save loses fall from 16 to 8. +- 2026-11-03: ODT paragraphs holding comments or tracked changes are written as read while those are unchanged (gap 19): a comment edited, resolved or answered, or a change accepted, sends its paragraph to the model; the file's change regions are kept beside WordCraft's own. Over the corpus the words a save loses fall from 29 to 4. +- 2026-11-03: ODT paragraphs and tables holding pictures and shapes are written as read while unchanged (gap 19), with their pictures and frame styles; over the corpus the attributes and values a save loses halve (1,551 → 789), the pages unchanged. +- 2026-11-03: ODT sections inside text boxes are written as read (gap 19), as LibreOffice's letter and fax templates hold the sender's address; seven of those templates now round-trip pixel-identical; over the corpus 103 of 133 files do (95), the round-trip difference falls from 51k to 44k pixels, and the losses a save makes fall from 59 to 35. +- 2026-11-03: ODT saves lose less (gap 19): list styles sharing a paragraph style's name are kept apart (§16.2), named sections around tables (nested too) are written as read, unchanged charts keep their replacement picture, note citations follow the document's numbering, and frames keep their stacking order. Over the 133-file corpus the losses a save makes, file by file, fall from 242 to 59 (19 kinds), with the pages unchanged. +- 2026-11-03: ODT form definitions and indexes are written back as read (gap 19): a file's forms keep their settings, properties and event listeners, and an index nobody edited keeps its source settings and entry templates. Over the 133-file corpus the element kinds a save loses fall from 38 to 22 (losses per file 354 → 242), with the pages unchanged (95 pixel-identical, 130 with identical text). +- 2026-11-03: ODT saves without edits lose (almost) nothing (gap 19): tables, page layouts and master pages, named sections, document properties and declarations WordCraft doesn't model are written back as read, and a test saves seven samples of our own and finds nothing missing. Over the 133-file corpus 95 files come back pixel-identical (62 before) and the round-trip difference falls to 51k pixels (191k) +- 2026-11-03: ODT saves what WordCraft doesn't model (gap 19): a paragraph nobody edited is written back with its own markup, and while no style changed, the file's own style definitions are written, so unknown elements, attributes, unused list styles and note settings survive a save that doesn't touch them. Over the 133-file corpus (LibreOffice, Google Docs and ONLYOFFICE files) the round-trip difference falls by 64% and 62 files come back pixel-identical (46 before) +- 2026-11-03: ODT checked the other way round too (gap 19): 42 documents made in WordCraft (its templates, themes, and one per feature) are saved as ODT and opened in LibreOffice; every one opens with the same page count and passes the ODF 1.3 schema. Found and fixed: table styles were left out of the saved file (now written into each cell), ODT tables were given Word's Table Grid style on opening (which changed their spacing), section columns now say whether they end balanced, and SmartArt diagrams are saved as a group of their shapes and text. The LibreOffice round-trip corpus is at its best yet (40k differing pixels over 125 documents, 42 identical). +- 2026-11-02: ODT files follow their standard wherever it says something, and a setting wherever it doesn't (gap 19). File › Options › Advanced › OpenDocument files (and `file.options` `odf`) chooses between two display styles, Microsoft Word style (the default) and LibreOffice style, or custom values for each of twelve cases the standard leaves open: header heights and spacing, column gaps, shape colours and hairlines, where tab stops are measured from, frame relations the standard doesn't define, and lines of only pictures. A page layout that states no margins follows the setting too: 0 in Microsoft Word style (XSL's initial value), 2 cm for LibreOffice. Every case is listed in docs/odf-interpretation.md; Google Docs and ONLYOFFICE files checked, and neither needs a preset of its own yet. +- 2026-11-02: ODT form controls keep their place on the line and multi-line text fields their lines; custom shapes keep their own geometry (LibreOffice draws its own shape after a save). In-line pictures placed against the text are raised from its size, and saved standing on the baseline. The corpus round-trip difference falls to 41k pixels, 90% below this morning's 408k; 42 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged. +- 2026-11-02: ODT charts keep their whole look through a save: the chart object as read is written back while the chart is unchanged (edited, it's written from WordCraft's chart). Frames outlined by a drawn stroke keep it, and a header with no stated height is LibreOffice's 0.5 cm. The corpus round-trip difference falls to 47k pixels (408k this morning); 41 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged. +- 2026-11-02: ODT: saving no longer makes a table's first row repeat on every page (a table style's header row had been taken for Repeat as header row), so LibreOffice's 16 agenda templates round-trip with their text the same; 91 of the 125 corpus documents now do, and the round-trip difference falls to 60k pixels (408k this morning) (gap 19). Readiness unchanged. +- 2026-11-02: ODT documents open with LibreOffice's layout where it differs from Word (new `Settings::odf_layout`): a line of only pictures is as tall as they are, and a paragraph's padding without a border takes room. Unequal columns and column breaks are kept. The corpus round-trip difference falls to 82k pixels, 80% below this morning's 408k; LibreOffice's report templates and one-page résumé now round-trip almost pixel-identical (gap 19). Readiness unchanged. +- 2026-11-02: ODT, from LibreOffice's report templates and agenda: tables filling the margins fit them, and a table's own space above and below is kept and laid out (new `TableProps::space_before`/`space_after`); a header's spacing stays below one taller than its band (new `SectionProps::header_gap`/`footer_gap`); a picture alone in a paragraph keeps the paragraph's size and its raise. The corpus round-trip difference falls from 241k to 155k pixels, 62% below this morning's 408k (gap 19). Readiness unchanged. +- 2026-11-02: ODT: none of the 125 test documents (LibreOffice's 78 templates among them) changes its page count through a WordCraft save any more, and their round-trip difference falls by 40%: the default font size, page-anchored frames, styles used only in text boxes, empty paragraphs' size (a new paragraph-mark field in the flow model) and frames anchored by their style (gap 19). Readiness unchanged. +- 2026-11-02: Emphasis marks (Font › Emphasis mark: a dot, comma or circle over each character, or a dot under it) are new: drawn with room made in the line, set by `format.emphasisMark`, kept in DOCX (`w:em`) and ODT. SET fields now give REF their value, so ODT variables open and update as fields. A page break before a table's first row moves the table, as in Word. ODT: page background pictures, fixed-width tables, tables starting a page, the document's default font size and dates in the locale's order (gap 19). Readiness unchanged. +- 2026-11-02: ODT checked against LibreOffice's own 78 bundled templates (letters, faxes, agendas, reports, CVs), all of which now open and round-trip (gap 19): placeholders ("") open as content controls you click and type over; a "First Page" style followed by another is a different first page; pages without a size take the regional default and LibreOffice's 2 cm margins; WMF and EMF logos are kept; template dates show the day; USERNAME shows your name; hidden form controls and `.ott` templates (open and save, with a password too) are kept. Readiness unchanged. +- 2026-11-01: ODT at ~90% (gap 19): custom shapes LibreOffice draws from formulas (hexagons, smileys, cans, block arcs…) are drawn from their own geometry instead of as rectangles; table cell formulas open as formula fields that Update Field recalculates, and are saved as formulas LibreOffice calculates; macros and other package parts WordCraft doesn't read are kept on save; text turned down or up in table cells and text boxes stays turned; form controls keep their size, and buttons survive as buttons. Every change checked against LibreOffice by round trips compared page by page and against the ODF 1.3 schema. Readiness unchanged. +- 2026-11-01: ODT read and written nearly whole (gap 19, ~82%): drawing shapes, groups, rotation, arrowheads, dashes, gradients, polygons and paths; tables of contents and indexes (with their marks, so Update Index rebuilds them); bookmarks over text; dates in their formats, sequences, captions, chapters and references; page settings (page colour, borders, note numbering, line numbering); flat `.fodt`; drop caps; tracked formatting changes; charts, opened as editable charts and saved as LibreOffice charts; embedded spreadsheets and OLE objects kept whole (they were lost); form controls as content controls; password-protected files (LibreOffice's AES encryption) opened and saved; ruby; page breaks after paragraphs, kept lines, widow control, hyphenation, table alignment and row heights. Layout: in-line pictures move with their run's raise or lowering, and text in a turned text box turns with it. Every change checked against LibreOffice by round trips compared page by page and against the ODF 1.3 schema. Readiness unchanged. +- 2026-11-01: ODT: text boxes keep their text on open (it was dropped) and save as text boxes (gap 19). Readiness unchanged. +- 2026-11-01: ODT: saved files carry LibreOffice's layout settings (the file's own, or Word-compatible ones), so LibreOffice lays them out as WordCraft does; list items keep their contextual spacing (gap 19). Readiness unchanged. +- 2026-11-01: ODT: custom character styles stay styles on open and save (gap 19). Readiness unchanged. +- 2026-11-01: ODT: custom paragraph styles (LibreOffice's Text body, your own) stay styles on open and save; their formatting used to become direct formatting (gap 19). Readiness unchanged. +- 2026-11-01: ODT: equations are saved as LibreOffice formula objects (MathML), so they stay equations in LibreOffice; they were saved as plain text (gap 19). Readiness unchanged. +- 2026-11-01: ODT: floating pictures keep their position and text wrapping on open and save; they used to drop into the text line (gap 19). Readiness unchanged. +- 2026-11-01: ODT: paragraph borders and background colours open and save (gap 19). Readiness unchanged. +- 2026-11-01: ODT: numbered headings (1., 1.1.) keep their numbers on open and save (gap 19). Readiness unchanged. +- 2026-11-01: ODT: table cell borders and padding open and save; saved tables no longer draw doubled lines or a grid on borderless tables (gap 19). Readiness unchanged. +- 2026-11-01: ODT: headings, titles and quotations look as the file defines them on open, and as WordCraft defines them after a save (gap 19). Readiness unchanged. +- 2026-11-01: ODT: tab stops and their leaders open and save, at the positions LibreOffice shows (gap 19). Readiness unchanged. +- 2026-11-01: ODT: underline styles, double strike, caps, small caps, hidden text, letter spacing, scale, raised text, outline, shadow, relief and language open and save; monospaced fonts keep their names, and LibreOffice's default kerning is kept so lines break as they did (gap 19). Readiness unchanged. +- 2026-10-31: ODT: lists keep their numbering (i., A., (1), bullets) and stay separate lists as in LibreOffice; consecutive lists used to merge into one decimal list (gap 19). Readiness unchanged. +- 2026-10-31: ODT: comments open and save with their ranges, replies and resolved state, as LibreOffice keeps them (gap 19; they were dropped on open). Readiness unchanged. +- 2026-10-31: ODT: footnotes and endnotes open and save (they were dropped on open), as Word-style note stories with their custom marks (gap 19). Readiness unchanged. +- 2026-10-31: ODT: headers and footers are saved (they were dropped on every save), each page's header sits where LibreOffice puts it on open, and page numbers, page counts, dates and other text fields open as fields that update and are saved as ODF fields (gap 19). Readiness unchanged. +- 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged. +- 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result. +- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal. +- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison. +- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged. +- 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view. +- 2026-11-03: Arabic dialogs: the dialogs of the mirrored interface now read right to left. Rows of a label and its field, lists of columns (Compare, AutoCorrect, Special Characters, Track Changes Options, the Columns widths), the tile galleries (chart types, SmartArt layouts) and the ribbon Paragraph group start at the right edge, through an `rtl::Table` helper that is an `egui::Grid` in English; the Add >> and << Remove arrows swap, the Columns preset captions and the Macros buttons no longer overlap, and the Recipient List grid and About › Models scroll instead of running past the left edge. Readiness unchanged. +- 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. +- 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. +- 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. +- 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. +- 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged. +- 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged. +- 2026-11-03: Options › Language (CP4.3): **Your languages** lists English, every installed Language pack and the languages of the open document, each with where its proofing comes from (Built in, Installed, Your own dictionary, System dictionary, Grammar built in, Not available yet) and Install, Remove and Set as default; **Add a language…** opens a picker with search (dutch, nederlands, nl), sorted by name, and its first click is the consent to download. The Dictionaries folder moved here from Options › Proofing, with a Dictionary files list of the file each language found (#1063). Gap 8. - 2026-11-02: Language packs (CP4.4): craft-i18n's first pack release, language-packs-1, is published and WordCraft pins its index (sha256): 61 languages install from File › Options › Language, among them Lithuanian (#4707), Romanian (#3438), Catalan (#2895, #4947), French (#1325), Greek (#2561), Slovak (#3806), Serbian (#4968) and Latvian (#4438). Dutch (#4145, #2505), German and the other compounding languages wait on craft-proof engine support. Readiness unchanged. - 2026-11-01: Language packs (CP4.3–CP4.5): languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language, or from a bar a document in that language shows ("Polish proofing isn't installed. Install (4.6 MB)"), through the font store and its single download setting; About › Language data shows every licence; `proof.packs`, `proof.installPack` and `proof.removePack` for agents. Install works once language-packs-1 is published (until then with a test index); the default build then embeds English data only, and keeps today's carry-over data embedded until then (`cargo xtask language`). Lithuanian (#4707) and Romanian (#3438) are available as a Language pack once language-packs-1 is published; the Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs (#4145, #2505, #2895, #4947, #1325, #2561, #3806, #4968, #4438) are defined and wait on craft-proof engine support (FLAG long/num, OCONV, ISO 8859-7, CIRCUMFIX, FULLSTRIP). Readiness unchanged. - 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open.- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. @@ -150,6 +206,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-31: List numbers count as Word counts them when a deeper item comes first: a list that opens with 10.1–10.4 goes on with 11, not a second 10, and 1.1, 1.2 are followed by 2. A level above that hasn't counted yet takes its first number at the first deeper item. - 2026-11-01: Paragraph side borders lie outside the text, so a thick border no longer covers the first letter (#5036). - 2026-10-31: DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word, not with the document defaults alone, so a Pandoc footer naming an undefined `Footer` style is as tall as Word's and no longer pushes the last paragraph to the next page (#5077). Readiness unchanged. +- 2026-10-31: Table of contents: with Word's table over entries in columns, where the title sits in a one-column section above a continuous break, Insert Table of Contents with new settings keeps the title above the break and the entries in their columns (the new title went under the break, into the columns), and Remove Table of Contents takes the title, the blank paragraph and its break with the entries, as Word does (observed in Word for Mac); a paragraph of another style above the break stays (#4041). - 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097). - 2026-10-31: Keytips reach the Backstage: Alt, F badges its pages with Word's letters, and Alt, F, P (or V) opens Print (#4960). - 2026-10-29: Documents with hyperlinks or a table of contents print again on the desktop (#4734). The print job scales each page to the paper, and the spooler refused a page with link annotations ("flatten page annotations before using shrink-to-fit"); the PDF sent to the printer now omits link annotations, which print nothing; the link and table-of-contents text stays on the page. Exported PDFs and Save these pages as PDF (`file.printPdf`) keep the annotations. @@ -171,6 +228,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-27: File › Options › Proofing › AutoCorrect Options opens with no document open (#4585). On the Start screen, or after closing the last document, the button did nothing: AutoCorrect wasn't among the commands allowed without a document. Its options are user preferences, so changes made there are kept for the next document. - 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate. - 2026-10-29: HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. +- 2026-10-27: File › Print: Return prints, as in Word, where ⌘P opens on Print as the default button; a field or list on the page keeps Return while it has the keyboard. +- 2026-10-28: A window restored to a place no monitor covers now opens centred on the primary monitor. Before, a monitor replaced by one of the same size left the window off screen, and the app seemed to close as soon as it started. - 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document. - 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. @@ -191,6 +250,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-24: A line touching a floating table at its bottom edge keeps clear of the table (#3977), in both row-span selection and word skipping. Shapes keep their strict overlap rule. Original Source Sans 3 boundary and narrow-strip controls cover modes 11/12/14/15; the expected Word distinction is cyril265's Word for Mac 16.114.1 observation. - 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document. - 2026-10-24: AutoFormat As You Type has "Built-in Heading styles" (off by default): pressing Enter twice after a line of five or fewer words that ends in a letter or digit makes it Heading 1, or Heading 2 or 3 when the line starts with one or two tabs. +- 2026-10-25: Track Changes: when the heading AutoFormat removes tabs the same author typed, or the border-line AutoFormat puts a border on the first paragraph of a story, the formatting change is recorded under the author making it. Another author's earlier formatting change on that paragraph is replaced, so the change is no longer credited to that author and Reject All brings back the paragraph as that author left it. - 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison. - 2026-10-24: AutoFormat: a paragraph numbered through its style (a heading in a heading-linked Multilevel List) keeps `---` and `+---+` as text on Enter, as a paragraph numbered directly does. At the top of the document the border-line AutoFormat removed the characters and switched off the heading's number, and left no border. - 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982). @@ -232,15 +292,21 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture | +| 2026-11-03 | **Arabic interface (#1195):** `ar` selectable (and every `ar-*` system locale); craft-i18n catalog; mirrored ribbon, panes, File page, dialogs and menus; Arabic UI font fallback; document direction stays independent. No kashida or Arabic proofing yet; Hebrew UI still open. | | 2026-11-01 | **English spelling reads SCOWL 2026 (CP2, #40, #520, #1281, #1045):** en-US, en-GB (also en-AU, en-NZ, en-IE, en-IN…) and en-CA each check against their own SCOWL `rel-2026.02.25` Hunspell dictionary from craft-i18n, embedded and read by craft-proof's affix engine (which now understands `COMPOUNDRULE`, so ordinals like `21st` are words). Suggestions come from the dialect's own dictionary; our American/British policy (`en-variants.txt`) still decides the other dialect's spellings, because Word accepts more than SCOWL's one spelling per dialect (`organize` and `organise` in en-GB). Rare words the 1990s Moby list accepted (`aposiopesis`) are now flagged. The Moby list stays for hyphenation and as a fallback. The build carries 1.5 MB more Language data. | | 2026-11-01 | Proofing moved to the shared libraries, with nothing users see changing: the code is `craft-proof` in storytold/craft-libs (with its history) and the spelling and hyphenation data (the Moby list, our English patterns and word lists, the German, Polish, Portuguese and Italian patterns, `ru-extra`) is craft-i18n's `language-data/`, embedded from `CRAFT_I18N_DIR` at the commit in `craft-i18n.rev`. Before/after `wordcraft-cli` runs on a proofing document give the same issues, suggestions and hyphenated PDF (CP1). | +| 2026-10-28 | Fonts a document uses that aren't installed are no longer silent: the font box shows a warning mark (hover names the font drawn instead) and the status bar says how many are missing, listing them (the first 20, then how many more) with the faces drawn instead and Add Font…. The list is what layout actually drew, so hidden text, unused styles and orphaned stories don't count; substitutes that measure the same (Carlito for Calibri, Liberation for Arial) and Symbol/Wingdings bullets aren't flagged | | 2026-10-28 | **Arabic diacritic editing (#4757):** Backspace removes the last stored Arabic combining mark, so its base letter can stay while the mark is replaced. Caret movement and forward Delete keep their cluster rules. Synthetic command, history, protection and native/DOCX fresh-reopen checks; no native Word comparison or readiness change. | | 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) | +| 2026-11-03 | A password-protected file named on the command line (double-clicked in the file manager) asks for its password when the first blank document was replaced because File › Options chose a region; the window now follows that replacement at once, so the first frame no longer takes it for a document switch and drops the Password box (#5299) | | 2026-11-01 | Markdown export: a list inside a block quote stays in the quote (`> - item`), its continuation lines included; it was read as quoted list paragraphs but written without `>`, so the list moved out of the quote on every save | | 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point | | 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged | | 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. | | 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. | +| 2026-10-14 | Insert › Date & Time and DATE, TIME and other date fields show the computer's local date, time and weekday, daylight saving included, as Word does (#1707); they showed UTC, so a user in New Zealand at noon on 31 October got 30 October 23:00. Timestamps saved in files (revisions, comments, document properties) stay UTC. Backstage's greeting follows the local hour too | +| 2026-10-23 | File > Print: N-up physical sheets and left/right-bound booklets, shared preview/PDF geometry and padded copies (#641); printer jobs keep upstream’s link-annotation fix. | | 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | | 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged | | 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). | @@ -250,6 +316,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-25 | #4085 review: highlight toggling checks the full target with constant resolved-run storage; a different colour after 10,000 runs prevents clearing. | | 2026-10-24 | #4085: Ctrl+Alt+H toggles the last chosen highlight colour; explicit colour setters remain idempotent. Mixed, inherited and pending formatting and Undo/Redo covered by command/key regressions. | | 2026-10-23 | Comment times show in the user's own time zone, as in Word: the Comments pane converts them (it showed the file's UTC time) and the balloons show them under each author (they showed none). The file's UTC time (Word's `w16cex:dateUtc`) wins over `w:date`, a time naming its zone is converted, and one without a zone shows as written (#2322, #2337) | +| 2026-11-01 | trivial | N-up and booklet print imposition (#641): integrate current print-job link handling (#4734), preserve markup preview and sheet/copy planning; readiness estimates unchanged. | +| 2026-11-01 | trivial | Printing (#641): pin booklet page order and blank left/right slots for every count from five through nine pages and both bindings; integrate current main. Readiness estimates unchanged. | | 2026-10-22 | Compare results opened as new or revised untitled documents start without the previous document's version snapshots. A comparison or mailing result rejected by protection keeps the snapshots and save target with the original document. | | 2026-10-20 | The Document Inspector's removals are refused while the document tracks changes: they delete straight from the document, so the text would go with no revision to show for it and the revision trail would be broken behind the user's back | | 2026-10-20 | A version snapshot belongs to the document it was taken from: the snapshots went with the document, so a restore can only ever bring back a version of the document that is open | @@ -289,6 +357,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | +| 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows | | 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). | | 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes | | 2026-10-16 | English dialects (#1281, #1045): English (UK), (Australia), (Canada) and the other Englishes are proofed in their own spelling — *colour* is flagged in a US document and *color* in a British one, with the right form suggested first — from our own list of American/British spellings, so nothing to install. Review › Language lists them, the .docx `w:lang` keeps them, and an English interface on a British (or Canadian, Australian…) system starts new documents in that English | @@ -360,6 +429,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. | | 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in | |---|---| +| 2026-10-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) | | 2026-10-26 | Spelling & Grammar opens a right-side Proofing task pane observed in Word for Windows: sentence context, directly clickable suggestion cards and ignore/dictionary actions. Current-story traversal and stale-target checks are retained. Right-click keeps its compact menu. | | 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures | | 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. | @@ -692,6 +762,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps ## Revision history +> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. +> > 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged. > > 2026-10-28 · Markdown import bounds reference-label scans and normalization within the existing link-work allowance. Nested undefined labels preserve literal text instead of causing quadratic work (#4658). @@ -702,11 +774,29 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Progress log: OpenDocument display settings removed (gap 19) | +| 2026-10-31 | trivial | Progress log: dialogs are modal except the five Word keeps modeless; a press beside one shakes it (#3859) | +| 2026-11-04 | trivial | Progress log: picture brightness, contrast and transparency (#1711, #1766) | +| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) | +| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) | +| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) | +| 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output | +| 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged | +| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged | +| 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) | +| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) | +| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) | +| 2026-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) | +| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) | +| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) | +| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | +| 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | | 2026-11-01 | trivial | Progress log: Language packs install from File › Options › Language and the missing-pack bar once language-packs-1 is published (CP4) | | 2026-11-01 | trivial | Progress log: English spelling reads SCOWL 2026 (CP2) | | 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes | -| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open || 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) | +| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | +| 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) | | 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged | | 2026-10-31 | trivial | Progress log: a table row doesn't split off a picture that doesn't fit (#4272) | | 2026-11-01 | trivial | Progress log: a middle or bottom cell of a row split across pages lines up on the first page | @@ -736,7 +826,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | | 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | +| 2026-10-27 | trivial | Progress log: Return on File › Print prints, as Word's default Print button does | | 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | +| 2026-10-14 | trivial | Progress log: Insert Date and Time and DATE/TIME fields use local time (#1707) | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | @@ -1105,6 +1197,15 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) | | 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) | | 2026-10-28 | trivial | Progress log: chart and SmartArt text the font lacks is drawn in a fallback face (#4720) | +| 2026-11-02 | trivial | Progress log: ODT controls, custom shapes, in-line placement (gap 19) | +| 2026-11-02 | trivial | Progress log: ODT charts kept, frame outlines, header height (gap 19) | +| 2026-11-02 | trivial | Progress log: ODT header rows (gap 19) | +| 2026-11-02 | trivial | Progress log: ODT layout setting, padding, columns (gap 19) | +| 2026-11-02 | trivial | Progress log: ODT tables, header spacing, picture paragraphs (gap 19) | +| 2026-11-02 | trivial | Progress log: ODT round trips keep their page count (gap 19) | +| 2026-11-02 | trivial | Progress log: emphasis marks, SET fields, ODT page backgrounds and tables (gap 19) | +| 2026-11-02 | trivial | Progress log: ODT checked against LibreOffice's templates (gap 19) | +| 2026-11-01 | trivial | Progress log: ODT at ~90% (gap 19) | | 2026-10-29 | trivial | Progress log: corporate authors in bibliographies (#4848) | | 2026-10-29 | trivial | Progress log: Table Properties follows the selection (#4857) | | 2026-11-01 | trivial | Progress log: a list inside a Markdown block quote stays quoted | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 580e28e6c6..b3e9a4d8c5 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -10,7 +10,7 @@ use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome}; use wordcraft_ui_egui::{Inbox, Services, WordApp}; const DOC_EXTS: &[&str] = - &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"]; + &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "ott", "fodt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"]; const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS; mod font_store; @@ -655,6 +655,8 @@ fn mime_for(name: &str) -> &'static str { Some("pdf") => "application/pdf", Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Some("odt") => "application/vnd.oasis.opendocument.text", + Some("ott") => "application/vnd.oasis.opendocument.text-template", + Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml", Some("rtf") => "application/rtf", Some("html") => "text/html", Some("md" | "txt") => "text/plain", diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 667298a72b..d6e11ef8e8 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -239,6 +239,8 @@ fn format_name(ext: &str) -> String { "html" => "Web Page".into(), "rtf" => "Rich Text Format".into(), "odt" => "OpenDocument Text".into(), + "ott" => "OpenDocument Text Template".into(), + "fodt" => "Flat OpenDocument Text".into(), "tex" => "LaTeX".into(), "png" => "PNG Image".into(), "json" => "JSON".into(), diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index d4ba832362..6b723be720 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -27,7 +27,7 @@ mod traffic_lights; use wordcraft_engine::Session; use wordcraft_ui_egui::{ Services, UiState, WordApp, - window_geometry::{RawWindowState, WindowGeometry, take_rescue}, + window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue}, }; /// The app, the restored window geometry (or, with none, the default: no position) until the @@ -54,7 +54,13 @@ impl eframe::App for App { apple_events::poll(&self.5, &mut self.0, ctx); self.0.logic(ctx); self.3.show(frame); - let window = self.4; + let mut window = self.4; + // Only until the off-screen check below has run: it is the one reader. + if self.1.is_some() { + let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point()); + window.on_a_monitor = on_a_monitor; + window.primary_monitor_rect = primary_monitor_rect; + } // A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit // there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first // knows the monitor: the off-screen check below takes the pending geometry then. @@ -126,6 +132,37 @@ impl eframe::App for App { } } +/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds. +/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot +/// report the window or primary monitor (Wayland, headless). +fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option, Option) { + let Some(w) = frame.winit_window() else { + return (None, None); + }; + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return (None, None); + } + let Some(pos) = w.outer_position().ok() else { + return (None, None); + }; + let size = w.outer_size(); + let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| { + egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32)) + }; + let monitors: Vec = + w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect(); + let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors)); + let primary_monitor_rect = w.primary_monitor().map(|m| { + let pos = m.position(); + let size = m.size(); + egui::Rect::from_min_size( + egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point, + egui::vec2(size.width as f32, size.height as f32) / pixels_per_point, + ) + }); + (on_a_monitor, primary_monitor_rect) +} + /// Quit the way Command-Q does, on the next turn of the main queue. /// /// Called when the window is closing. Doing it on the next turn lets the close @@ -496,7 +533,7 @@ fn main() -> eframe::Result { // document too (#838). if !sample && app.session.prefs.region.is_some() { let doc = app.session.blank_document(); - app.session.set_document(doc); + app.replace_startup_document(doc); } app.watch_desktop_theme(&cc.egui_ctx); if prefs_enabled() diff --git a/contributors/people.toml b/contributors/people.toml index b95d43ea85..950a26a2b5 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -35,7 +35,9 @@ sync_github_name = false real_name = "Saeed Azarehgar" [people.vangel76] -sync_github_name = true +real_name = "Valentin Klein" +display_name = "vangel76" +sync_github_name = false [people.dkhlapov] real_name = "Dmitrii Khlapov" @@ -187,3 +189,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.entropybeater] +display_name = "entropybeater" diff --git a/craft-i18n.rev b/craft-i18n.rev index 60aae6a4c3..b223714530 100644 --- a/craft-i18n.rev +++ b/craft-i18n.rev @@ -1 +1 @@ -059cbf95c2a3eb95d637ca8584faca1129d17f48 +9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66 diff --git a/crates/control-key/src/lib.rs b/crates/control-key/src/lib.rs index 1c562f40ab..1f56b58686 100644 --- a/crates/control-key/src/lib.rs +++ b/crates/control-key/src/lib.rs @@ -1,5 +1,6 @@ //! The control channel's key file, one rule for the app (which writes it) and the MCP bridge //! (which reads it): `/control-key.`. See `docs/control-protocol.md`, Keys. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::path::{Path, PathBuf}; diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 6da6ec45f7..e6c7f41c0c 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -10,8 +10,9 @@ workspace = true [features] # Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`, -# `zip_guard::zip64_entry_count_sample`); turned on only as a -# dev-dependency, so never built into the app. +# `zip_guard::zip64_entry_count_sample`) and the step count of +# `work::count`; turned on only as a dev-dependency, so never built into +# the app. test-support = [] [dependencies] diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 8f69ee35ae..68602fff15 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -213,6 +213,12 @@ pub struct ShapeExtra { /// main colour (the shape's `stroke`). #[serde(skip_serializing_if = "Option::is_none")] pub outline_fill: Option, + /// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and + /// handles), whose path the shape's freeform was worked out from: saved back as it was, so + /// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with + /// it). Dropped when the shape is made another kind. + #[serde(skip_serializing_if = "Option::is_none")] + pub odf_geometry: Option>, /// A horizontal rule (VML `o:hr`: a web page's `
` pasted into Word): it takes its whole /// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width. #[serde(skip_serializing_if = "Option::is_none")] @@ -384,7 +390,6 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32 (0.0, 1.0) } } - ShapeKind::Line => (w, -h), _ => (w, h), }; let len = (dx * dx + dy * dy).sqrt(); diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index 4647706892..b971e5e4bd 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -254,6 +254,18 @@ pub struct ContentControl { /// `w:sdtEndPr`, kept verbatim (XML). #[serde(skip_serializing_if = "String::is_empty")] pub end_pr_xml: String, + /// The size of the form control it came from (an ODF `draw:control`'s `svg:width` and + /// `svg:height`), points: kept for saving back as one. + #[serde(skip_serializing_if = "Option::is_none")] + pub form_size: Option<[f32; 2]>, + /// How far that form control stands raised from the baseline (points, negative lowered: + /// an ODF control hanging from the baseline is lowered by its height), kept for saving back. + #[serde(skip_serializing_if = "Option::is_none")] + pub form_raise: Option, + /// A form control WordCraft has no kind for (an ODF button, image button, label…): its form + /// XML, kept verbatim and saved back as it was. It shows its label, locked. + #[serde(skip_serializing_if = "String::is_empty")] + pub form_xml: String, } impl ContentControl { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a1df2b1618..c39d15625b 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings}; use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk}; use std::sync::Arc; -use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section}; +use super::{BTreeMap, CharProps, Document, InlineObject, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section}; /// [`Document::media`] by content, so adding media is linear in the bytes added rather than a /// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; @@ -62,8 +62,8 @@ impl Document { body: vec![para_block(Paragraph::new())], last_section: SectionProps::default(), parts: BTreeMap::new(), - styles: StyleSheet::builtin(), - numbering: Numbering::default(), + styles: Arc::new(StyleSheet::builtin()), + numbering: Arc::default(), comments: BTreeMap::new(), revisions: Vec::new(), settings: Settings::default(), @@ -76,6 +76,7 @@ impl Document { passthrough: BTreeMap::new(), glossary: Vec::new(), media_index: MediaIndex::default(), + kept: Default::default(), } } @@ -152,6 +153,7 @@ impl Document { passthrough, glossary, media_index: _, + kept: _, } = self; let Document { body: other_body, @@ -171,6 +173,7 @@ impl Document { passthrough: other_passthrough, glossary: other_glossary, media_index: _, + kept: _, } = other; same_blocks(body, other_body) && same_parts(parts, other_parts) @@ -734,6 +737,52 @@ impl Document { } } + /// [`Document::objects_mut`] for filling in what an object shows from what it is (a chart's + /// drawing from its data), which doesn't change the document: the markup kept as read for + /// the paragraphs and tables holding them (see [`crate::kept`]) follows them. + pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) { + // The stories (text boxes', notes') as read before, when filling changes one: they stay + // as read after it, so the paragraphs showing them keep their markup. + let holds = |b: &Arc| { + let mut any = false; + edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants)); + any + }; + let as_read: Vec = match self.kept.get() { + Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => { + k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect() + } + _ => Vec::new(), + }; + let mut moves: Vec = Vec::new(); + 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() { + let mut any = false; + edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants)); + if !any { + continue; + } + let before = crate::kept::identities(b); + edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| { + if p.objects.iter().any(wants) { + p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f); + p.touch(); + } + }); + moves.push((before, crate::kept::identities(b))); + } + } + let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect(); + if let Some(k) = self.kept.get_mut() { + for (before, after) in moves { + k.follow(&before, &after); + } + k.story_fps.extend(refreshed); + } + } + /// 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) { diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 0d426937e1..265a563596 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -228,6 +228,11 @@ fn block_cost(b: &Block) -> (usize, bool) { .saturating_add(p.objects.len().saturating_mul(std::mem::size_of::())); let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o))); cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects); + // The formatting a clone copies too: style ids, font names and the like in the runs, + // the mark and the paragraph's properties. + let runs = p.runs.iter().filter(|r| !r.props.is_empty()).fold(0usize, |n, r| n.saturating_add(serialized_len(&r.props))); + let props = serialized_len(&p.props).saturating_add(serialized_len(&p.mark)).saturating_add(p.section.as_ref().map_or(0, serialized_len)); + cost = cost.saturating_add(runs).saturating_add(props); boxes |= box_ids(p).next().is_some(); }); (cost, boxes) @@ -1049,7 +1054,7 @@ impl Document { let mut default_style = None; if keep_styles { if src.styles.get("Normal").is_none() { - src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() }); + Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() }); } // Ids given out here are taken too: two styles never get the same new one. let mut given = std::collections::HashSet::new(); @@ -1084,7 +1089,7 @@ impl Document { abs_map.insert(a.id, id); let mut a = crate::numbering::AbstractNum { id, ..a.clone() }; a.levels.iter_mut().for_each(|l| restyle(&mut l.style)); - self.numbering.abstracts.push(a); + Arc::make_mut(&mut self.numbering).abstracts.push(a); } let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); let mut nums = BTreeMap::new(); @@ -1094,7 +1099,7 @@ impl Document { let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() }; n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style)); - self.numbering.nums.push(n); + Arc::make_mut(&mut self.numbering).nums.push(n); } let renumber = |n: &mut Option| { if let Some(n) = n.as_mut() @@ -1133,7 +1138,7 @@ impl Document { } if self.styles.get(&st.id).is_none() { renumber(&mut st.para.numbering); - self.styles.upsert(st); + Arc::make_mut(&mut self.styles).upsert(st); } } let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX); diff --git a/crates/doc/src/edit/tests.rs b/crates/doc/src/edit/tests.rs index eaebae22a6..ae7f7d64af 100644 --- a/crates/doc/src/edit/tests.rs +++ b/crates/doc/src/edit/tests.rs @@ -415,6 +415,24 @@ fn pasted_box_copies_are_capped_in_bytes() { assert_eq!(got.iter().flatten().count(), added); } +#[test] +fn pasted_box_copies_count_the_formatting_they_copy() { + // A story holding a text box and a run whose font name is 1 MiB: each copy clones the + // formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap. + let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() }; + let mut p = Paragraph::with_text("x", props); + p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let f = fan_out(vec![para_block(p)], 64); + let mut d = doc(&["x"]); + let before = d.parts.len(); + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let added = d.parts.len() - before; + let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::(); + let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum(); + assert!((1..64).contains(&added), "{added}"); + assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}"); +} + #[test] fn pasted_box_copies_count_the_objects_they_copy() { // #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs @@ -645,13 +663,15 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() { #[test] fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() { - // 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph: - // the innermost paragraphs come first in the document but the paragraph walk stops short. + // The deepest nesting allowed, each table in the first cell of the one around it, ahead of + // that cell's paragraph: the innermost paragraphs come first in the document but the + // paragraph walk stops short. + const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) }; let mut t = Table::new(1, 1, 200.0); if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))]; } - for _ in 0..20 { + for _ in 1..MAX_TABLE_DEPTH { let mut outer = Table::new(1, 1, 200.0); if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { cell.blocks.insert(0, Arc::new(Block::Table(t))); @@ -672,19 +692,19 @@ fn adopting_a_document_renumbers_what_it_refers_to() { let mut ours = doc(&["ours"]); let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]); ours.comments.insert(0, Comment { part: our_note, ..Comment::default() }); - let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); + let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet); ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() }); let mut theirs = doc(&["theirs"]); let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]); let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]); theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() }); - let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); + let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet); assert_eq!(list, our_list); theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() }); let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() }; style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 }); - theirs.styles.upsert(style); + Arc::make_mut(&mut theirs.styles).upsert(style); let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 }); p.runs[0].props.del = Some(0); @@ -728,11 +748,11 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() { use crate::styles::{Style, StyleKind, StyleSheet}; let mut ours = doc(&["ours"]); let mut theirs = doc(&["theirs"]); - theirs.styles = StyleSheet::empty(); - theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() }; + theirs.styles = Arc::new(StyleSheet::empty()); + Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() }; let big = CharProps { size: Some(20.0), ..Default::default() }; - theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() }); - theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() }); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() }); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() }); let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); p.props.style = Some("Big".into()); p.runs[0].props.style = Some("Link".into()); @@ -746,15 +766,15 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() { fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() { use crate::styles::Style; let mut ours = doc(&["ours"]); - ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); let mut theirs = doc(&["theirs"]); - theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); - let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered); let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap(); - let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap(); + let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap(); a.levels[0].style = Some("Heading1".into()); let level = a.levels[0].clone(); - theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level)); + Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level)); let before = ours.numbering.abstracts.len(); ours.adopt_with(theirs, true); @@ -794,10 +814,10 @@ fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() { use crate::{Revision, RevisionKind}; for keep in [false, true] { let mut ours = doc(&["ours"]); - let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); + let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet); let mut theirs = doc(&["theirs"]); theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); - let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); + let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet); assert_eq!(list, our_list); let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); // Live and old props name the same styles (ours has them too) and their list. @@ -825,8 +845,8 @@ fn adopted_styles_never_become_our_defaults() { let mut ours = doc(&["ours"]); let mut theirs = doc(&["theirs"]); // A localized default character style, and one whose id we have too. - theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() }); - theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() }); ours.adopt_with(theirs, keep); assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}"); } diff --git a/crates/doc/src/graphic.rs b/crates/doc/src/graphic.rs index 64902eb2a2..f0e87b52de 100644 --- a/crates/doc/src/graphic.rs +++ b/crates/doc/src/graphic.rs @@ -51,6 +51,17 @@ pub struct Embedded { pub refs: Vec, } +impl Embedded { + /// The root element of an ODF embedded object's markup: its files are kept in + /// [`crate::Document::passthrough`] for the ODT writer; DOCX shows it as its picture. + pub const ODF_OBJECT: &'static str = " bool { + self.xml.trim_start().starts_with(Self::ODF_OBJECT) + } +} + /// One relationship an [`Embedded`] object uses. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct EmbeddedRef { diff --git a/crates/doc/src/kept.rs b/crates/doc/src/kept.rs new file mode 100644 index 0000000000..ee0d3e9a1a --- /dev/null +++ b/crates/doc/src/kept.rs @@ -0,0 +1,780 @@ +//! Markup kept from the file a document was opened from, so that what isn't edited is written +//! back as it was: a paragraph keeps its source XML, which a writer uses as long as the +//! paragraph still is what the reader made of it (its [`fingerprint`] unchanged). Anything the +//! model has no place for (an unknown element, an attribute nobody reads) survives a save that +//! way. Never saved with the document (WordCraft's own format), and not part of its content +//! (`==` ignores it). + +use std::collections::{BTreeMap, HashMap}; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use crate::Paragraph; + +/// Most kept markup per document, bytes (a hostile file can't make the kept copy grow without +/// bound: past it, paragraphs are written from the model as before). +pub const MAX_KEPT_BYTES: usize = 64 * 1024 * 1024; + +/// The kept markup of a document (see the module docs). +#[derive(Clone, Debug, Default)] +pub struct Kept(Option>); + +/// What a reader kept. +#[derive(Clone, Debug, Default)] +pub struct KeptXml { + /// The format it came from (`"odt"`): only that format's writer uses it. + pub format: String, + /// Paragraph [`fingerprint`] → the paragraph's markup, in the order read (identical + /// paragraphs keep the first's). + pub paras: HashMap>, + /// The same for the body's tables ([`table_fingerprint`]). + pub tables: HashMap>, + /// While reading: the markup of the body's `n`th table, until [`Kept::seal`]. + pub pending_tables: Vec<(usize, Arc)>, + /// Wrappers kept as read around paragraphs (sections): for a paragraph's fingerprint, the + /// start tags opening before it and how many close after it. Only paragraphs that were the + /// only one of their kind have them (a copy can't open a section twice). + pub wraps: HashMap>, u32)>, + /// While reading: the same by paragraph `rev`, until [`Kept::seal`]. + pub pending_wraps: HashMap>, u32)>, + /// The same for paragraphs with twins (an empty paragraph has many): by fingerprint and + /// [`Paragraph::rev`], which stays while the paragraph does (a writer opens each once). + pub twin_wraps: HashMap<(u64, u64), (Vec>, u32)>, + /// The same wrappers around the body's tables, by [`table_fingerprint`] (tables that are + /// the only one of their kind). + pub table_wraps: HashMap>, u32)>, + /// While reading: those of the body's `n`th table, until `seal_kept`. + pub pending_table_wraps: Vec<(usize, (Vec>, u32))>, + /// Indexes kept as read: by their first paragraph's fingerprint, how many top-level + /// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]), + /// and the index's markup. Written as read while those paragraphs are all as read. + pub indexes: HashMap)>, + /// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup. + pub pending_indexes: HashMap)>, + /// The group of paragraphs written as read together (see `wordcraft_formats::model::Group`) + /// each paragraph kept belongs to, by its fingerprint: the group's id and size. + pub groups: HashMap, + /// While reading: the same by paragraph `rev`, until `seal_kept`. + pub pending_groups: HashMap, + /// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph + /// showing one is written as read only while it is as read. + pub story_fps: HashMap, + /// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a + /// paragraph holding comments is written as read only while they are as read. + pub comment_fps: HashMap, + /// Named definitions the kept markup refers to (styles, font faces…), by a key the format + /// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read. + pub defs: BTreeMap>, + /// While reading: paragraph [`Paragraph::rev`] → its markup, until [`Kept::seal`] turns + /// them into fingerprints. + pub pending: HashMap>, + /// Bytes kept so far. + pub bytes: usize, + /// The keys of [`KeptXml::defs`] in the order the file had them. + pub order: Vec, + /// Files of the package kept definitions refer to (a style's fill picture), by path. + pub files: BTreeMap>>, + /// The document's style definitions' [`styles_fingerprint`] when read: while it is the + /// same, the file's own style definitions can be written as they were. + pub styles_fp: Option, + /// The document's page setup's [`pages_fingerprint`] when read: while it is the same, the + /// file's own page layouts and master pages can be written as they were. + pub pages_fp: Option, + /// Each section's master page, as the file named it where its section starts (`None`: the + /// file's default), for a writer naming the file's own master pages. + pub section_masters: Vec>, +} + +impl PartialEq for Kept { + /// Not part of the document's content. + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl Kept { + pub fn new(x: KeptXml) -> Kept { + Kept(Some(Arc::new(x))) + } + pub fn get(&self) -> Option<&KeptXml> { + self.0.as_deref() + } + /// What `format`'s writer may use, if anything was kept from such a file. + pub fn of(&self, format: &str) -> Option<&KeptXml> { + self.get().filter(|k| k.format == format) + } + pub fn get_mut(&mut self) -> Option<&mut KeptXml> { + self.0.as_mut().map(Arc::make_mut) + } + pub fn clear(&mut self) { + self.0 = None; + } + /// After reading: each paragraph still marked pending (by its `rev`, which any change after + /// the reader made it would have moved on) gets its markup under its fingerprint. + pub fn seal(&mut self, paras: &mut dyn Iterator) { + let Some(k) = self.get_mut() else { return }; + if k.pending.is_empty() { + return; + } + for p in paras { + if let Some(xml) = k.pending.remove(&p.rev) { + k.paras.entry(fingerprint(p)).or_insert(xml); + } + } + k.pending.clear(); + } +} + +impl KeptXml { + /// The markup kept for paragraph `p`, while it still is what was read. + pub fn para(&self, p: &Paragraph) -> Option<&Arc> { + if self.paras.is_empty() { + return None; + } + self.paras.get(&fingerprint(p)) + } + /// The markup kept for table `t`, while it still is what was read. + pub fn table(&self, t: &crate::Table) -> Option<&Arc> { + if self.tables.is_empty() { + return None; + } + self.tables.get(&table_fingerprint(t)) + } + /// Whether the comments paragraph `p` holds (those it begins, with their replies) are as + /// read and are all the comments its kept markup `xml` holds: a reply written elsewhere + /// in the file, or one added since, isn't in it. + pub fn comments_as_read(&self, doc: &crate::Document, p: &Paragraph, xml: &str) -> bool { + let in_markup = xml.matches("").count(); + let mut count = 0usize; + for o in &p.objects { + if let crate::para::InlineObject::CommentStart { id } = o { + let (fp, n) = comments_fingerprint(doc, *id); + if self.comment_fps.get(id) != Some(&fp) { + return false; + } + count = count.saturating_add(n); + } + } + count == in_markup + } + /// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are + /// as read. + pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool { + // A note's citation is its number in the document's numbering, which is the page setup's. + let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. })); + if notes && !self.pages_as_read(doc) { + return false; + } + p.objects + .iter() + .flat_map(|o| match o { + crate::para::InlineObject::NoteRef { id, .. } => vec![*id], + o => o.text_boxes(), + }) + .all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id))) + } + /// The wrappers kept around table `t`, while it still is what was read. + pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec>, u32)> { + if self.table_wraps.is_empty() { + return None; + } + self.table_wraps.get(&table_fingerprint(t)) + } + /// The wrappers kept around paragraph `p`, while it still is what was read. + pub fn wrap(&self, p: &Paragraph) -> Option<&(Vec>, u32)> { + if self.wraps.is_empty() && self.twin_wraps.is_empty() { + return None; + } + let fp = fingerprint(p); + self.wraps.get(&fp).or_else(|| self.twin_wraps.get(&(fp, p.rev))) + } + /// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their + /// [`run_fingerprint`] when read, its markup. + pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc)> { + if self.indexes.is_empty() { + return None; + } + self.indexes.get(&fingerprint(p)) + } + /// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`. + pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc) { + if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES { + self.bytes += xml.len(); + self.pending_indexes.insert(rev, (n, xml)); + } + } + /// The paragraph whose `rev` is `rev` belongs to group `group` (id, size). + pub fn keep_pending_group(&mut self, rev: u64, group: (u32, u32)) { + if self.pending_groups.len() < 100_000 { + self.pending_groups.insert(rev, group); + } + } + /// The group paragraph `p`'s markup kept as read belongs to (id, size). + pub fn group(&self, p: &Paragraph) -> Option<(u32, u32)> { + if self.groups.is_empty() { + return None; + } + self.groups.get(&fingerprint(p)).copied() + } + /// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget. + pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec>, u32)) { + let n: usize = wrap.0.iter().map(|t| t.len()).sum(); + if self.bytes.saturating_add(n) <= MAX_KEPT_BYTES { + self.bytes += n; + self.pending_wraps.insert(rev, wrap); + } + } + /// Keep wrapper markup for the body's `n`th table, within the budget. + pub fn keep_pending_table_wrap(&mut self, n: usize, wrap: (Vec>, u32)) { + let size: usize = wrap.0.iter().map(|t| t.len()).sum(); + if self.bytes.saturating_add(size) <= MAX_KEPT_BYTES { + self.bytes += size; + self.pending_table_wraps.push((n, wrap)); + } + } + /// Keep `xml` for the body's `n`th table, within the budget. + pub fn keep_pending_table(&mut self, n: usize, xml: Arc) { + if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES { + self.bytes += xml.len(); + self.pending_tables.push((n, xml)); + } + } + /// Keep `xml` for the paragraph whose `rev` is `rev`, within the budget. + pub fn keep_pending(&mut self, rev: u64, xml: Arc) { + if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES { + self.bytes += xml.len(); + self.pending.insert(rev, xml); + } + } + /// Keep a named definition, within the budget. + pub fn keep_def(&mut self, key: String, xml: Arc) { + if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES && !self.defs.contains_key(&key) { + self.bytes += xml.len(); + self.order.push(key.clone()); + self.defs.insert(key, xml); + } + } + /// Keep a file kept definitions refer to, within the budget. + pub fn keep_file(&mut self, path: String, bytes: Vec) { + if self.bytes.saturating_add(bytes.len()) <= MAX_KEPT_BYTES && !self.files.contains_key(&path) { + self.bytes += bytes.len(); + self.files.insert(path, Arc::new(bytes)); + } + } + /// Whether `doc`'s page setup is still as it was read. + pub fn pages_as_read(&self, doc: &crate::Document) -> bool { + let Some(fp) = self.pages_fp else { return false }; + let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc)); + fp == now + } + /// Whether `doc`'s style definitions are still as they were read. + pub fn styles_as_read(&self, doc: &crate::Document) -> bool { + self.styles_fp.is_some_and(|fp| fp == styles_fingerprint(doc)) + } +} + +/// What `doc`'s style definitions are: its styles, lists and default tab interval. +pub fn styles_fingerprint(doc: &crate::Document) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + match serde_json::to_vec(&(&doc.styles, &doc.numbering, doc.settings.default_tab)) { + Ok(v) => v.hash(&mut h), + Err(_) => u64::MAX.hash(&mut h), + } + h.finish() +} + +/// What a block is, as the kept markup knows it: its paragraphs' fingerprints and +/// [`Paragraph::rev`]s in order, and a table's [`table_fingerprint`]. +pub struct Identities { + paras: Vec<(u64, u64)>, + table: Option, +} + +/// A block's [`Identities`] before and after a change that keeps what it is. +pub type Moved = (Identities, Identities); + +/// The [`Identities`] of block `b`. +pub fn identities(b: &crate::Block) -> Identities { + let mut paras_of = Vec::new(); + match b { + crate::Block::Para(p) => paras_of.push(p), + crate::Block::Table(t) => { + for r in &t.rows { + for c in &r.cells { + paras(&c.blocks, 1, &mut paras_of); + } + } + } + } + Identities { paras: paras_of.iter().map(|p| (fingerprint(p), p.rev)).collect(), table: b.as_table().map(table_fingerprint) } +} + +impl KeptXml { + /// The markup kept for a block's paragraphs and table follows them from `before` to `after` + /// (what they were and are: see [`crate::Document::fill_objects`]). + pub fn follow(&mut self, before: &Identities, after: &Identities) { + for ((fp, rev), (nfp, nrev)) in before.paras.iter().zip(&after.paras) { + if fp == nfp && rev == nrev { + continue; + } + if let Some(x) = self.paras.remove(fp) { + self.paras.entry(*nfp).or_insert(x); + } + if let Some(w) = self.wraps.remove(fp) { + self.wraps.entry(*nfp).or_insert(w); + } + if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) { + self.twin_wraps.entry((*nfp, *nrev)).or_insert(w); + } + // Its group too: all a group's paragraphs are written as read, or none. + if let Some(g) = self.groups.remove(fp) { + self.groups.entry(*nfp).or_insert(g); + } + } + if let (Some(t), Some(nt)) = (before.table, after.table) + && t != nt + { + if let Some(x) = self.tables.remove(&t) { + self.tables.entry(nt).or_insert(x); + } + if let Some(w) = self.table_wraps.remove(&t) { + self.table_wraps.entry(nt).or_insert(w); + } + } + } +} + +/// How deep [`story_fingerprint`] follows stories in stories (text boxes in text boxes, notes). +const MAX_STORY_DEPTH: usize = 8; + +/// What story `id` (a text box's or a shape's text) is: its blocks, and the stories and notes +/// they hold in turn. +pub fn story_fingerprint(doc: &crate::Document, id: u32) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut todo = vec![(id, 0usize)]; + let mut seen = std::collections::HashSet::new(); + while let Some((id, depth)) = todo.pop() { + if !seen.insert(id) { + continue; + } + let blocks = doc.parts.get(&id).map(|p| &p.blocks); + match serde_json::to_vec(&(id, blocks)) { + Ok(v) => v.hash(&mut h), + Err(_) => u64::MAX.hash(&mut h), + } + if depth >= MAX_STORY_DEPTH { + continue; + } + let Some(blocks) = blocks else { continue }; + let mut held = Vec::new(); + paras(blocks, 0, &mut held); + for p in held { + for o in &p.objects { + todo.extend(o.text_boxes().into_iter().map(|s| (s, depth + 1))); + if let crate::para::InlineObject::NoteRef { id, .. } = o { + todo.push((*id, depth + 1)); + } + } + } + } + h.finish() +} + +/// Most replies a comment's fingerprint follows (hostile input). +const MAX_REPLIES: usize = 10_000; + +/// What comment `root` is with its replies (and theirs): each one's author, date, state and +/// text, and how many they are. +pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) { + family_fingerprint(doc, root, &scoped_replies(doc)) +} + +/// What a writer asks of a document's kept markup for many paragraphs, worked out once while +/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a +/// document of C comments took C² steps (and its page setup serialized once per note) to save. +struct Scope { + /// The document's address: it can't move or change while borrowed for the writing. + doc: usize, + replies: Option>>>, + pages: Option, +} + +thread_local! { + static SCOPE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +thread_local! { + /// How many times [`replies`] was built (tests). + static REPLY_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Puts the scope before it back when dropped (when `f` returns or unwinds). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let prev = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = prev); + } +} + +/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once. +pub fn while_writing(doc: &crate::Document, f: impl FnOnce() -> R) -> R { + let key = std::ptr::from_ref(doc) as usize; + let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None }))); + let _restore = Restore(prev); + f() +} + +/// The scope for `doc`, if it is being written: `f` of it. +fn in_scope(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option { + let key = std::ptr::from_ref(doc) as usize; + SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f)) +} + +/// [`replies`], built once while `doc` is being written. +fn scoped_replies(doc: &crate::Document) -> std::rc::Rc>> { + in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc))) +} + +/// Each comment's replies, by the comment's id. +fn replies(doc: &crate::Document) -> HashMap> { + #[cfg(test)] + REPLY_BUILDS.with(|n| n.set(n.get() + 1)); + let mut out: HashMap> = HashMap::new(); + for (id, c) in &doc.comments { + if let Some(p) = c.parent { + out.entry(p).or_default().push(*id); + } + } + out +} + +/// [`comments_fingerprint`], with each comment's `replies`. +fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap>) -> (u64, usize) { + let mut ids = vec![root]; + let mut seen = std::collections::HashSet::from([root]); + let mut i = 0; + while let Some(&id) = ids.get(i) { + for r in replies.get(&id).map(Vec::as_slice).unwrap_or(&[]) { + // (A reply naming itself, or a circle of them, is followed once.) + if ids.len() < MAX_REPLIES && seen.insert(*r) { + ids.push(*r); + } + } + i += 1; + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + for id in &ids { + let c = doc.comments.get(id); + let text = c.and_then(|c| doc.parts.get(&c.part)).map(|p| &p.blocks); + match serde_json::to_vec(&(c, text)) { + Ok(v) => v.hash(&mut h), + Err(_) => u64::MAX.hash(&mut h), + } + } + (h.finish(), ids.len()) +} + +/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins, +/// columns, headers and footers), the headers' and footers' content, the page colour and +/// picture, and the notes' numbering. +pub fn pages_fingerprint(doc: &crate::Document) -> u64 { + let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect(); + sections.push(&doc.last_section); + let parts: Vec<(&u32, &crate::Part)> = + doc.parts.iter().filter(|(_, p)| matches!(p.kind, crate::PartKind::Header | crate::PartKind::Footer)).collect(); + let s = &doc.settings; + let mut h = std::collections::hash_map::DefaultHasher::new(); + match serde_json::to_vec(&( + sections, + parts, + &s.page_color, + &s.page_picture, + (&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr), + (&doc.footnote_separators, &doc.endnote_separators), + )) { + Ok(v) => v.hash(&mut h), + Err(_) => u64::MAX.hash(&mut h), + } + h.finish() +} + +/// What a run of paragraphs is in the model, together. +pub fn run_fingerprint<'a>(paras: impl Iterator) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + for p in paras { + fingerprint(p).hash(&mut h); + } + h.finish() +} + +/// What table `t` is in the model. +pub fn table_fingerprint(t: &crate::Table) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + match serde_json::to_vec(t) { + Ok(v) => v.hash(&mut h), + Err(_) => u64::MAX.hash(&mut h), + } + h.finish() +} + +/// What paragraph `p` is in the model (its text, runs, objects and properties, not its `rev`): +/// equal for a paragraph nobody changed. +pub fn fingerprint(p: &Paragraph) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + match serde_json::to_vec(p) { + Ok(v) => v.hash(&mut h), + // Unserializable (no paragraph is): a value no paragraph's markup is kept under. + Err(_) => u64::MAX.hash(&mut h), + } + h.finish() +} + +/// Deepest table nesting [`crate::Document::seal_kept`] looks into. +const MAX_SEAL_DEPTH: usize = 16; + +fn paras<'a>(bl: &'a crate::Blocks, depth: usize, out: &mut Vec<&'a Paragraph>) { + for b in bl { + match &**b { + crate::Block::Para(p) => out.push(p), + crate::Block::Table(t) if depth < MAX_SEAL_DEPTH => { + for r in &t.rows { + for c in &r.cells { + paras(&c.blocks, depth + 1, out); + } + } + } + crate::Block::Table(_) => {} + } + } +} + +impl crate::Document { + /// After reading: the markup kept for paragraphs still as the reader made them is filed + /// under their fingerprints (see [`Kept::seal`]); the rest is dropped. + pub fn seal_kept(&mut self) { + // The body's tables kept, by their place among its tables; its indexes, by their run of + // top-level paragraphs. + if let Some(k) = self.kept.get_mut() { + let pending = std::mem::take(&mut k.pending_indexes); + for (i, b) in self.body.iter().enumerate() { + let Some(p) = b.as_para() else { continue }; + let Some((n, xml)) = pending.get(&p.rev) else { continue }; + let run: Option> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect()); + if let Some(run) = run.filter(|r| r.len() == *n) { + k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone())); + } + } + let pending = std::mem::take(&mut k.pending_tables); + let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect(); + for (n, xml) in pending { + if let Some(t) = tables.get(n) { + k.tables.entry(table_fingerprint(t)).or_insert(xml); + } + } + // Wrappers, for tables that are the only one of their kind (a copy can't open a + // section twice). + let pending = std::mem::take(&mut k.pending_table_wraps); + if !pending.is_empty() { + let fps: Vec = tables.iter().map(|t| table_fingerprint(t)).collect(); + for (n, w) in pending { + if let Some(fp) = fps.get(n) + && fps.iter().filter(|f| *f == fp).count() == 1 + { + k.table_wraps.insert(*fp, w); + } + } + } + } + // The text boxes' and shapes' stories as read, for paragraphs showing them. + if self.kept.get().is_some_and(|k| !k.pending.is_empty()) { + let fps: HashMap = self + .parts + .iter() + .filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote)) + .map(|(id, _)| (*id, story_fingerprint(self, *id))) + .collect(); + if let Some(k) = self.kept.get_mut() { + k.story_fps = fps; + } + } + // The comments as read, for paragraphs holding them. + if self.kept.get().is_some_and(|k| !k.pending.is_empty()) && !self.comments.is_empty() { + let replies = replies(self); + let fps: HashMap = + self.comments.iter().filter(|(_, c)| c.parent.is_none()).map(|(id, _)| (*id, family_fingerprint(self, *id, &replies).0)).collect(); + if let Some(k) = self.kept.get_mut() { + k.comment_fps = fps; + } + } + if self.kept.get().is_none_or(|k| k.pending.is_empty() && k.pending_wraps.is_empty()) { + return; + } + let mut all = Vec::new(); + paras(&self.body, 0, &mut all); + for p in self.parts.values() { + paras(&p.blocks, 0, &mut all); + } + let mut kept = std::mem::take(&mut self.kept); + // Groups, by fingerprint; a group with a paragraph that has twins (which a writer can't + // tell apart) isn't kept. + if let Some(k) = kept.get_mut() + && !k.pending_groups.is_empty() + { + let mut count: HashMap = HashMap::new(); + let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect(); + for (_, fp) in &fps { + *count.entry(*fp).or_default() += 1; + } + let pending = std::mem::take(&mut k.pending_groups); + let mut broken = std::collections::HashSet::new(); + let mut members: Vec<(u64, u64, (u32, u32))> = Vec::new(); + for (rev, fp) in &fps { + if let Some(g) = pending.get(rev) { + if count.get(fp) != Some(&1) { + broken.insert(g.0); + } + members.push((*rev, *fp, *g)); + } + } + for (rev, fp, g) in members { + if broken.contains(&g.0) { + k.pending.remove(&rev); + } else { + k.groups.insert(fp, g); + } + } + } + // Wrappers, for paragraphs that are the only one of their kind. + if let Some(k) = kept.get_mut() + && !k.pending_wraps.is_empty() + { + let mut count: HashMap = HashMap::new(); + let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect(); + for (_, fp) in &fps { + *count.entry(*fp).or_default() += 1; + } + for (rev, fp) in fps { + if let Some(w) = k.pending_wraps.remove(&rev) { + if count.get(&fp) == Some(&1) { + k.wraps.insert(fp, w); + } else { + k.twin_wraps.insert((fp, rev), w); + } + } + } + k.pending_wraps.clear(); + } + kept.seal(&mut all.into_iter()); + self.kept = kept; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_paragraph_keeps_its_markup_until_it_changes() { + let mut p = Paragraph::with_text("Hello", Default::default()); + let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() }); + if let Some(k) = kept.get_mut() { + k.keep_pending(p.rev, Arc::from("Hello")); + } + kept.seal(&mut std::iter::once(&p)); + let k = kept.of("odt").unwrap(); + assert_eq!(k.para(&p).map(|x| &**x), Some("Hello")); + assert!(k.pending.is_empty()); + // The same paragraph again (a copy, its rev moved on): still what was read. + p.touch(); + assert!(k.para(&p).is_some()); + // Changed: written from the model. + p.text.push('!'); + assert!(k.para(&p).is_none()); + assert!(kept.of("docx").is_none()); + } + + #[test] + fn a_paragraph_changed_while_reading_keeps_nothing() { + let mut p = Paragraph::with_text("Hi", Default::default()); + let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() }); + if let Some(k) = kept.get_mut() { + k.keep_pending(p.rev, Arc::from("Hi")); + } + p.touch(); + kept.seal(&mut std::iter::once(&p)); + assert!(kept.of("odt").unwrap().paras.is_empty()); + } + + #[test] + fn kept_markup_is_bounded() { + let mut k = KeptXml::default(); + let big: Arc = Arc::from("x".repeat(MAX_KEPT_BYTES / 2 + 1)); + k.keep_pending(1, big.clone()); + k.keep_pending(2, big); + assert_eq!(k.pending.len(), 1); + } + + /// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data) + /// doesn't change the document: a paragraph's group and a text box's story stay as read. + #[test] + fn filling_objects_keeps_groups_and_stories_as_read() { + use crate::para::InlineObject; + let field = + || InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }; + let mut doc = crate::Document::from_text("Body"); + let mut boxed = Paragraph::with_text("In the box", Default::default()); + boxed.insert_object(0, field(), &Default::default()).unwrap(); + let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]); + doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap(); + let fp = fingerprint(doc.body[0].as_para().unwrap()); + let mut k = KeptXml { format: "odt".into(), ..Default::default() }; + k.paras.insert(fp, Arc::from("Body")); + k.groups.insert(fp, (1, 1)); + k.story_fps.insert(id, story_fingerprint(&doc, id)); + doc.kept = Kept::new(k); + doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| { + if let InlineObject::Field { result, .. } = o { + *result = "filled".into(); + } + }); + let p = doc.body[0].as_para().unwrap(); + assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved"); + let k = doc.kept.get().unwrap(); + assert!(k.para(p).is_some()); + assert_eq!(k.group(p), Some((1, 1))); + assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id))); + } + + /// Writing a document checks each kept paragraph's comments against what was read: the + /// comments' replies are looked up once for the whole document, not once per comment. + #[test] + fn writing_many_comments_looks_their_replies_up_once() { + use crate::para::InlineObject; + let n = 50u32; + let text = (0..n).map(|i| format!("Para {i}")).collect::>().join("\n"); + let mut doc = crate::Document::from_text(&text); + for i in 0..n { + let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]); + doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() }); + doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize)) + .unwrap() + .insert_object(0, InlineObject::CommentStart { id: i }, &Default::default()) + .unwrap(); + } + let mut k = KeptXml { format: "odt".into(), ..Default::default() }; + for i in 0..n { + k.comment_fps.insert(i, comments_fingerprint(&doc, i).0); + } + let xml = "x"; + let before = REPLY_BUILDS.with(std::cell::Cell::get); + let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml))); + assert!(all, "every paragraph's comment is as read"); + assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1); + // Outside a writing, nothing is kept between calls. + assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml))); + assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index bcdd02948e..3928e79734 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -2,9 +2,9 @@ //! //! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers, //! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables; -//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is -//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they -//! touch (`Arc::make_mut`). +//! table cells hold block lists of their own. Blocks, the style sheet and the list definitions +//! are `Arc`-shared, so cloning a document is cheap (undo snapshots, handing a frozen copy to the +//! renderer) and edits copy only what they touch (`Arc::make_mut`). //! //! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths //! compare in document order. @@ -28,6 +28,7 @@ pub mod fill; pub mod freeform; pub mod graphic; pub mod ime_text; +pub mod kept; pub mod legacy_form; pub mod line_dash; pub mod link; @@ -39,6 +40,7 @@ pub mod math_linear; pub mod math_symbols; pub mod numbering; pub mod para; +pub mod picfx; pub mod props; pub mod resolve; pub mod section; @@ -47,6 +49,7 @@ pub mod styles; pub mod symbol_fonts; pub mod table; pub mod wordart; +pub mod work; pub mod wrap; pub mod zip_guard; @@ -84,7 +87,7 @@ pub use error::{DocError, Result}; pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id}; pub use position::{Path, Pos, StoryRef}; pub use settings::{ - COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source, + COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source, THEME_COLORS, Watermark, Zoom, ZoomFit, }; pub use text_walk::count_words; @@ -98,8 +101,12 @@ pub struct Document { /// Properties of the last section (OOXML's body-level `w:sectPr`). pub last_section: SectionProps, pub parts: BTreeMap, - pub styles: StyleSheet, - pub numbering: Numbering, + /// Shared, like the blocks: an undo step copies it only when a command changes it (a full + /// copy is kilobytes a style, so typing into a document with hundreds of styles cost megabytes + /// a step). + pub styles: Arc, + /// Shared like [`Document::styles`]. + pub numbering: Arc, pub comments: BTreeMap, pub revisions: Vec, pub settings: Settings, @@ -127,6 +134,10 @@ pub struct Document { /// by `==`). #[serde(skip)] media_index: MediaIndex, + /// Markup kept from the file it was opened from, for writing back what isn't edited (see + /// [`kept`]); never saved, ignored by `==`. + #[serde(skip)] + pub kept: kept::Kept, } #[cfg(test)] diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs index 9bb08b90e0..7ceeabb37b 100644 --- a/crates/doc/src/link.rs +++ b/crates/doc/src/link.rs @@ -1,5 +1,6 @@ -//! Which hyperlink targets may leave the document: followed by the OS, or written into an -//! exported HTML/Markdown file where a browser would run them. +//! Hyperlink targets: where a `HYPERLINK` field code points, and which targets may leave the +//! document: followed by the OS, or written into an exported HTML/Markdown file where a browser +//! would run them. /// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only, /// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading @@ -39,10 +40,121 @@ fn scheme(url: &str) -> Option { (first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase()) } +/// Where a `HYPERLINK` field code points, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) +/// or `#bookmark`. None for any other field, or one with no target. +pub fn hyperlink_target(instr: &str) -> Option { + // Only a `HYPERLINK` code is split up (a field's code may run to megabytes). + if !instr.trim_start().get(..9).is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) { + return None; + } + let args = field_args(instr); + let mut args = args.iter().peekable(); + if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) { + return None; + } + let is_switch = |(a, quoted): &&(String, bool)| !quoted && a.starts_with('\\'); + let (mut url, mut anchor) = (None, None); + while let Some(arg @ (a, _)) = args.next() { + if !is_switch(&arg) { + url = url.or(Some(a.as_str())); + continue; + } + // `\l` names a bookmark, `\o` a tooltip, `\t` a frame, `\*` `\#` `\@` a format; `\m` and `\n` take none. + let takes = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t" | "\\*" | "\\#" | "\\@"); + let value = takes.then(|| args.next_if(|v| !is_switch(v))).flatten(); + if a.eq_ignore_ascii_case("\\l") + && let Some((v, _)) = value + { + anchor = Some(v.as_str()); + } + } + let url = url.map(str::trim).filter(|u| !u.is_empty()); + let anchor = anchor.map(|a| a.trim().trim_start_matches('#')).filter(|a| !a.is_empty()); + match (url, anchor) { + // `\l` replaces a fragment the address already has, as in Word. + (Some(u), Some(a)) => Some(format!("{}#{a}", u.split_once('#').map_or(u, |(base, _)| base))), + (Some(u), None) => Some(u.to_string()), + (None, Some(a)) => Some(format!("#{a}")), + (None, None) => None, + } +} + +/// A field code's words: the field's name, its switches and its arguments, each with whether +/// it was quoted. In quotes a backslash escapes the next character, as in Word. +fn field_args(instr: &str) -> Vec<(String, bool)> { + let mut out = Vec::new(); + let mut chars = instr.chars().peekable(); + while let Some(c) = chars.next() { + if c.is_whitespace() { + continue; + } + let mut word = String::new(); + if c == '"' { + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => word.extend(chars.next()), + _ => word.push(c), + } + } + out.push((word, true)); + } else { + word.push(c); + while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') { + word.push(c); + } + out.push((word, false)); + } + } + out +} + #[cfg(test)] mod tests { use super::*; + /// Every reader's `HYPERLINK` cases: switches with and without values, quotes, escapes, spelling. + #[test] + fn hyperlink_field_codes_name_their_targets() { + for (code, want) in [ + (r#"HYPERLINK "http://example.com" \l "heading""#, Some("http://example.com#heading")), + (r#"HYPERLINK \l "heading""#, Some("#heading")), + (r#"HYPERLINK \l "_Toc1" "#, Some("#_Toc1")), + (r#"Hyperlink "http://example.com/a path""#, Some("http://example.com/a path")), + ("HYPERLINK https://example.org/b", Some("https://example.org/b")), + ("HYPERLINK\t\"http://example.com\"", Some("http://example.com")), + (r#"HYPERLINK"http://example.com""#, Some("http://example.com")), + (r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")), + (r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")), + (r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)), + (r#"HYPERLINK " http://example.com ""#, Some("http://example.com")), + // Switches that take a value, before or after the address, and those that take none. + (r#"HYPERLINK "http://example.com" \o "Help text""#, Some("http://example.com")), + (r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#, Some("https://example.org/")), + (r#"HYPERLINK \l "bm" \o "tip""#, Some("#bm")), + (r#"HYPERLINK \l "Sec" \m"#, Some("#Sec")), + (r#"HYPERLINK \n "http://example.com""#, Some("http://example.com")), + (r#"HYPERLINK \l "x" \* MERGEFORMAT"#, Some("#x")), + (r#"HYPERLINK "http://example.com" \l \o "tip""#, Some("http://example.com")), + (r#"HYPERLINK \l "bm" \l"#, Some("#bm")), + // A bookmark's own `#`, an address that has a fragment, and an empty address. + (r##"HYPERLINK "http://example.com" \l "#top""##, Some("http://example.com#top")), + (r#"HYPERLINK "http://example.com/#a" \l "b""#, Some("http://example.com/#b")), + (r#"HYPERLINK "" \l "x""#, Some("#x")), + // No target, or not a `HYPERLINK` field. + ("HYPERLINK", None), + (r#"HYPERLINK "" \o "tip""#, None), + (r#"HYPERLINKS "https://example.org/""#, None), + (r#"HYPERLINKX "http://example.com""#, None), + (r#"HYPERLINK\l "x""#, None), + ("PAGEREF _Toc1 \\h", None), + ("ab日本語", None), + ("", None), + ] { + assert_eq!(hyperlink_target(code).as_deref(), want, "{code:?}"); + } + } + #[test] fn only_web_and_mail_links_are_followable() { for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] { diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 3959bad1d1..0ed90870b1 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -231,7 +231,7 @@ impl Paragraph { /// Number of objects in `text[..off]`. fn objects_before(&self, off: usize) -> usize { - self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0) + self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0) } /// The object at byte `off` (which must hold U+FFFC). @@ -250,13 +250,13 @@ impl Paragraph { } /// Byte offsets of all objects, in order. pub fn object_offsets(&self) -> Vec { - self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect() + self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect() } /// (byte offset, object) for every object, in text order, in one pass. Prefer it to /// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before /// each offset again, so a loop over a paragraph's objects is quadratic (#3614). pub fn objects_with_offsets(&self) -> impl Iterator + '_ { - self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter()) + self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter()) } /// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length. diff --git a/crates/doc/src/para/inline_object.rs b/crates/doc/src/para/inline_object.rs index d10bbb9385..56c54d4197 100644 --- a/crates/doc/src/para/inline_object.rs +++ b/crates/doc/src/para/inline_object.rs @@ -19,6 +19,9 @@ pub enum InlineObject { /// Crop (left, top, right, bottom) fractions 0..1. #[serde(default)] crop: [f32; 4], + /// Brightness, contrast and transparency read from the file (see [`crate::picfx`]). + #[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")] + fx: crate::picfx::PicFx, /// The OLE object (embedded or linked file) or Word signature line this picture shows, as /// read from a file: saving writes the object back, not only its picture. See /// [`crate::graphic::Embedded`]. diff --git a/crates/doc/src/picfx.rs b/crates/doc/src/picfx.rs new file mode 100644 index 0000000000..21d5bcf5b3 --- /dev/null +++ b/crates/doc/src/picfx.rs @@ -0,0 +1,190 @@ +//! Picture colour corrections read from a file: brightness, contrast and transparency +//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4; +//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets). +//! +//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is +//! the picture as it is. +//! +//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into +//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with +//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is +//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255 +//! only at the end: +//! +//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast / +//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0` +//! +//! (as if half the brightness shift were applied before the contrast and half after). Measured +//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level): +//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510; +//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) → +//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0); +//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8. +//! +//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3, +//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and +//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`. + +use serde::{Deserialize, Serialize}; + +/// The largest magnitude of a correction (100 %). +pub const FX_MAX: i32 = 100_000; + +/// Brightness, contrast and transparency of a picture (see the module docs). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct PicFx { + /// −100 000 (black) ..= 100 000 (white). + pub bright: i32, + /// −100 000 (flat grey) ..= 100 000 (hard threshold). + pub contrast: i32, + /// 0 (opaque) ..= 100 000 (invisible). + pub transparency: i32, +} + +impl PicFx { + /// Corrections clamped to their ranges. + pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx { + PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) } + } + + /// The same corrections, each clamped to its range (for values that came from anywhere). + pub fn clamped(self) -> PicFx { + PicFx::new(self.bright, self.contrast, self.transparency) + } + + /// No correction at all. + pub fn is_identity(&self) -> bool { + *self == PicFx::default() + } + + /// Brightness or contrast changes the pixels (transparency alone doesn't). + pub fn changes_pixels(&self) -> bool { + let f = self.clamped(); + f.bright != 0 || f.contrast != 0 + } + + /// The picture's opacity, 0..=1 (1 − transparency). + pub fn opacity(&self) -> f32 { + 1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32 + } + + /// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to + /// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the + /// black level (see the module docs). Non-finite values count as missing. + pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx { + let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 }; + let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 }; + // s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0). + let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain }; + let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32; + PicFx::new(fx(2.0 * black), fx(c), 0) + } + + /// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]). + pub fn to_vml(&self) -> (f64, f64) { + (self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0) + } + + /// The contrast slope `s` (capped at 255: contrast 100 % is a threshold). + fn slope(&self) -> f64 { + let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64; + if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) } + } + + /// The lookup table every colour channel goes through. + pub fn table(&self) -> [u8; 256] { + let s = self.slope(); + let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64; + let mut t = [0u8; 256]; + for (x, slot) in t.iter_mut().enumerate() { + let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s); + *slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 }; + } + t + } + + /// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The + /// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a + /// trailing partial pixel is ignored. + pub fn apply_rgba(&self, rgba: &mut [u8]) { + if !self.changes_pixels() { + return; + } + let t = self.table(); + for px in rgba.as_chunks_mut::<4>().0 { + for c in px.iter_mut().take(3) { + *c = t.get(usize::from(*c)).copied().unwrap_or(*c); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export). + #[test] + fn matches_word() { + let cases: [(PicFx, [u8; 3]); 6] = [ + (PicFx::new(40_000, -40_000, 0), [132, 209, 255]), + (PicFx::new(80_000, -80_000, 0), [224, 249, 255]), + (PicFx::new(-40_000, 40_000, 0), [0, 0, 203]), + (PicFx::new(0, 80_000, 0), [0, 130, 255]), + (PicFx::new(20_000, 0, 0), [51, 179, 255]), + (PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]), + ]; + for (fx, want) in cases { + let t = fx.table(); + for (x, w) in [0usize, 128, 255].into_iter().zip(want) { + let got = t[x]; + assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}"); + } + } + } + + #[test] + fn channels_apart_alpha_kept() { + let fx = PicFx::new(80_000, -80_000, 0); + let mut px = [255, 0, 0, 77, 0, 128, 255, 255]; + fx.apply_rgba(&mut px); + // Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0). + assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]); + let mut odd = [0u8; 6]; + fx.apply_rgba(&mut odd); + assert_eq!(odd[4..], [0, 0]); + } + + #[test] + fn vml_mapping() { + // 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5. + let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0); + assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}"); + assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0)); + assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0)); + assert!(PicFx::from_vml(1.0, 0.0).is_identity()); + for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] { + let fx = PicFx::from_vml(g, b); + assert_eq!(fx, fx.clamped(), "{g} {b}"); + let _ = fx.table(); + } + } + + #[test] + fn hostile_values_clamp() { + for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] { + let fx = PicFx { bright: v, contrast: v, transparency: v }; + let _ = fx.table(); + assert!((0.0..=1.0).contains(&fx.opacity())); + let mut px = [1u8, 2, 3, 4]; + fx.apply_rgba(&mut px); + assert_eq!(px[3], 4); + } + assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75); + assert!(!PicFx::new(0, 0, 25_000).changes_pixels()); + // Contrast 100 %: a threshold at mid-grey, no NaN. + let t = PicFx::new(0, FX_MAX, 0).table(); + assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255)); + } +} diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 68a2c998bc..a801081845 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -23,12 +23,15 @@ mod para_props; mod table_props; pub use char_props::{ - CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign, + CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign, }; pub use color::{Rgb, TextColor}; -pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame}; +pub use para_props::{ + Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame, +}; pub use table_props::{ - CellProps, HeightRule, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor, + CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, + anchor_ooxml, valign_from_anchor, }; #[cfg(test)] diff --git a/crates/doc/src/props/char_props.rs b/crates/doc/src/props/char_props.rs index 32757edc89..a4aaf88604 100644 --- a/crates/doc/src/props/char_props.rs +++ b/crates/doc/src/props/char_props.rs @@ -291,6 +291,10 @@ pub struct CharProps { /// Kerning for fonts at or above this size (points); 0 = off. #[serde(skip_serializing_if = "Option::is_none")] pub kern: Option, + /// A mark over (or under) each character (`w:em`, Font › Emphasis mark), as East Asian text + /// stresses words; `Some(EmphasisMark::None)` turns off one a style sets. + #[serde(skip_serializing_if = "Option::is_none")] + pub emphasis: Option, /// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and /// contextual ligatures. #[serde(skip_serializing_if = "Option::is_none")] @@ -381,6 +385,41 @@ pub struct EastAsianLayout { pub vert_compress: Option, } +/// A mark over or under each character (`ST_Em`, ECMA-376 §17.18.24): Word's Font › Emphasis +/// mark. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum EmphasisMark { + #[default] + None, + /// A dot above. + Dot, + /// A comma (sesame) above. + Comma, + /// A hollow circle above. + Circle, + /// A dot below. + UnderDot, +} + +impl EmphasisMark { + pub const ALL: [EmphasisMark; 5] = [EmphasisMark::None, EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot]; + /// OOXML `w:em` value. + pub fn ooxml(self) -> &'static str { + match self { + EmphasisMark::None => "none", + EmphasisMark::Dot => "dot", + EmphasisMark::Comma => "comma", + EmphasisMark::Circle => "circle", + EmphasisMark::UnderDot => "underDot", + } + } + /// The mark an OOXML `w:em` value names; unknown values are none. + pub fn from_ooxml(v: &str) -> EmphasisMark { + EmphasisMark::ALL.into_iter().find(|m| m.ooxml() == v.trim()).unwrap_or_default() + } +} + /// Brackets around two lines in one (`ST_CombineBrackets`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -466,7 +505,7 @@ impl CharProps { } fn minus_run_visibility(&mut self, base: &CharProps) { - drop_same_fields!(self, base; web_hidden, snap_to_grid); + drop_same_fields!(self, base; web_hidden, snap_to_grid, emphasis); } fn minus_script_style(&mut self, base: &CharProps) { @@ -497,7 +536,7 @@ impl CharProps { } fn overlay_run_visibility(&mut self, patch: &CharProps) { - overlay_fields!(self, patch; web_hidden, snap_to_grid); + overlay_fields!(self, patch; web_hidden, snap_to_grid, emphasis); } fn overlay_script_style(&mut self, patch: &CharProps) { diff --git a/crates/doc/src/props/para_props.rs b/crates/doc/src/props/para_props.rs index 29a8b86850..e72e3ef94b 100644 --- a/crates/doc/src/props/para_props.rs +++ b/crates/doc/src/props/para_props.rs @@ -26,6 +26,40 @@ impl Align { } } +/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`, +/// ECMA-376 §17.3.1.39). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignment { + Auto, + Top, + Center, + Baseline, + Bottom, +} + +impl TextAlignment { + pub fn ooxml(self) -> &'static str { + match self { + TextAlignment::Auto => "auto", + TextAlignment::Top => "top", + TextAlignment::Center => "center", + TextAlignment::Baseline => "baseline", + TextAlignment::Bottom => "bottom", + } + } + pub fn from_ooxml(s: &str) -> Option { + match s { + "auto" => Some(TextAlignment::Auto), + "top" => Some(TextAlignment::Top), + "center" => Some(TextAlignment::Center), + "baseline" => Some(TextAlignment::Baseline), + "bottom" => Some(TextAlignment::Bottom), + _ => None, + } + } +} + /// Line spacing rule. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "rule", content = "value")] @@ -107,6 +141,18 @@ pub enum BorderStyle { Triple, DotDash, Wave, + /// The compound lines of thin and thick strokes (`thinThickSmallGap` to + /// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single + /// line of the border's width. + ThinThickSmallGap, + ThickThinSmallGap, + ThinThickThinSmallGap, + ThinThickMediumGap, + ThickThinMediumGap, + ThinThickThinMediumGap, + ThinThickLargeGap, + ThickThinLargeGap, + ThinThickThinLargeGap, } impl BorderStyle { @@ -121,6 +167,15 @@ impl BorderStyle { BorderStyle::Triple => "triple", BorderStyle::DotDash => "dotDash", BorderStyle::Wave => "wave", + BorderStyle::ThinThickSmallGap => "thinThickSmallGap", + BorderStyle::ThickThinSmallGap => "thickThinSmallGap", + BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap", + BorderStyle::ThinThickMediumGap => "thinThickMediumGap", + BorderStyle::ThickThinMediumGap => "thickThinMediumGap", + BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap", + BorderStyle::ThinThickLargeGap => "thinThickLargeGap", + BorderStyle::ThickThinLargeGap => "thickThinLargeGap", + BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap", } } pub fn from_ooxml(s: &str) -> BorderStyle { @@ -133,6 +188,15 @@ impl BorderStyle { "triple" => BorderStyle::Triple, "dotDash" | "dotDotDash" => BorderStyle::DotDash, "wave" | "doubleWave" => BorderStyle::Wave, + "thinThickSmallGap" => BorderStyle::ThinThickSmallGap, + "thickThinSmallGap" => BorderStyle::ThickThinSmallGap, + "thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap, + "thinThickMediumGap" => BorderStyle::ThinThickMediumGap, + "thickThinMediumGap" => BorderStyle::ThickThinMediumGap, + "thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap, + "thinThickLargeGap" => BorderStyle::ThinThickLargeGap, + "thickThinLargeGap" => BorderStyle::ThickThinLargeGap, + "thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap, _ => BorderStyle::Single, } } @@ -183,6 +247,11 @@ impl Borders { pub fn any_visible(&self) -> bool { [self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible) } + /// A side is drawn, or a paragraph side without a line holds padding (ODF's, which keeps + /// its room in LibreOffice's layout). + pub fn takes_room(&self) -> bool { + self.any_visible() || [self.top, self.left, self.bottom, self.right].iter().flatten().any(|b| b.space.is_finite() && b.space > 0.0) + } /// Apply every `Some` side of `patch` on top of `self`. pub fn overlay(&mut self, patch: &Borders) { if patch.top.is_some() { @@ -363,6 +432,10 @@ pub struct ParaProps { /// `w:snapToGrid`: lines snap to the section's document grid. Unset = on. #[serde(skip_serializing_if = "Option::is_none")] pub snap_to_grid: Option, + /// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the + /// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_alignment: Option, } impl ParaProps { @@ -383,7 +456,7 @@ impl ParaProps { } fn overlay_script_layout(&mut self, patch: &ParaProps) { - overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn); + overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment); } pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps { self.overlay(patch); diff --git a/crates/doc/src/props/table_props.rs b/crates/doc/src/props/table_props.rs index 8461ffc029..312644d8bb 100644 --- a/crates/doc/src/props/table_props.rs +++ b/crates/doc/src/props/table_props.rs @@ -158,6 +158,12 @@ pub struct TableProps { /// the right margin. #[serde(skip_serializing_if = "std::ops::Not::not")] pub bidi_visual: bool, + /// Space above and below a table in the text flow, points (ODF's table `fo:margin-top` and + /// `fo:margin-bottom`; Word has none). Added to the spacing of the paragraphs around it. + #[serde(skip_serializing_if = "Option::is_none")] + pub space_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space_after: Option, } /// Where a floating table sits; the text after it wraps around it. @@ -294,6 +300,38 @@ pub struct CellProps { /// Tracked formatting change (`w:tcPrChange`). #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, + /// A picture behind the cell's content (an ODF cell's `style:background-image`; Word has + /// none). + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option>, +} + +/// A cell's background picture: drawn at its own size where `align` puts it, tiled, or stretched +/// over the cell, and cut off at the cell's edges. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CellPicture { + /// Key into [`crate::Document::media`]. + pub media: String, + /// Its own size, points. + pub size: [f32; 2], + pub repeat: PictureRepeat, + /// Where an unrepeated picture sits: 0 the left (top) edge, 0.5 the middle, 1 the right + /// (bottom) edge. + pub align: [f32; 2], +} + +/// How a background picture fills its area (ODF §20.348 `style:repeat`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PictureRepeat { + /// Once, at its own size. + #[default] + None, + /// Repeated across the area. + Tile, + /// Stretched over the area. + Stretch, } impl CellProps { diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 0192713d02..9eb64721d3 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -41,6 +41,9 @@ pub struct ResolvedChar { /// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned. #[serde(default)] pub kern: f32, + /// A mark over or under each character (Font › Emphasis mark). + #[serde(default)] + pub emphasis: crate::props::EmphasisMark, /// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and /// contextual). #[serde(default)] @@ -97,6 +100,7 @@ impl ResolvedChar { scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0), position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0), kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0), + emphasis: c.emphasis.unwrap_or_default(), ligatures: c.ligatures, outline: c.outline.unwrap_or(false), shadow: c.shadow.unwrap_or(false), @@ -178,6 +182,10 @@ pub struct ResolvedPara { pub tabs: Vec, pub shading: Option, pub borders: Option, + /// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model + /// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it. + #[serde(default)] + pub padding: [f32; 4], pub bidi: bool, pub drop_cap: u8, /// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`). @@ -265,6 +273,10 @@ impl ResolvedPara { tabs, shading: p.shading, borders: p.borders.filter(Borders::any_visible), + padding: p.borders.map_or([0.0; 4], |b| { + [b.top, b.left, b.bottom, b.right] + .map(|s| s.filter(|l| !l.is_visible() && l.space.is_finite() && l.space > 0.0).map_or(0.0, |l| l.space.min(1584.0))) + }), bidi: p.bidi.unwrap_or(false), drop_cap: p.drop_cap.unwrap_or(0).min(10), drop_cap_margin: p.drop_cap_margin.unwrap_or(false), @@ -323,6 +335,38 @@ impl StyleSheet { (pp, cp) } + /// What a table style's paragraph formatting `table` changes for a paragraph with `p`, once + /// the paragraph style chain is applied over it (ECMA-376 §17.7.2): each property it gives + /// that the chain doesn't set. Formats without table styles state these directly. + pub fn table_para_patch(&self, p: &ParaProps, table: &ParaProps) -> ParaProps { + let style = p.style.clone().unwrap_or_else(|| "Normal".into()); + let mut with = self.default_para.clone(); + with.overlay(&ParaProps { style: None, ..table.clone() }); + let (with, _) = self.para_style_props_over(&style, with, CharProps::default()); + let (without, _) = self.para_style_props_over(&style, self.default_para.clone(), CharProps::default()); + diff_patch(&with, &without) + } + + /// What a table style's run formatting `table` changes for a run of character style + /// `run_style` in a paragraph of style `para_style`, once the paragraph and character style + /// chains are applied over it (ECMA-376 §17.7.2). + pub fn table_char_patch(&self, para_style: Option<&str>, run_style: Option<&str>, table: &CharProps) -> CharProps { + let chain = |defaults: CharProps| { + let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults); + if let Some(cs) = run_style { + for s in self.chain(cs) { + if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph { + cp.overlay(&s.chr); + } + } + } + cp + }; + let mut with = self.default_chr.clone(); + with.overlay(&CharProps { style: None, ..table.clone() }); + diff_patch(&chain(with), &chain(self.default_chr.clone())) + } + /// Resolve a paragraph's properties. pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara { self.resolve_para_in(p, None) @@ -387,6 +431,16 @@ impl StyleSheet { } } +/// The properties of `with` that differ from `without` (both fully resolved property sets of one +/// kind), as a set of only those; empty when serialisation fails. +fn diff_patch(with: &T, without: &T) -> T { + let (Ok(serde_json::Value::Object(w)), Ok(serde_json::Value::Object(o))) = (serde_json::to_value(with), serde_json::to_value(without)) else { + return T::default(); + }; + let patch: serde_json::Map = w.into_iter().filter(|(k, v)| o.get(k) != Some(v)).collect(); + serde_json::from_value(serde_json::Value::Object(patch)).unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; @@ -524,4 +578,15 @@ mod tests { assert_eq!(p.tabs.len(), 3); assert_eq!(p.tabs[0].pos, 100.0); } + + /// Resolved formatting saved before paragraph padding existed still reads: no padding. + #[test] + fn resolved_para_without_padding_reads() { + let p = StyleSheet::builtin().resolve_para(&ParaProps::default()); + let mut v = serde_json::to_value(&p).unwrap(); + v.as_object_mut().unwrap().remove("padding"); + let back: ResolvedPara = serde_json::from_value(v).unwrap(); + assert_eq!(back.padding, [0.0; 4]); + assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p }); + } } diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 5090a6352e..7541c35d02 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -733,6 +733,13 @@ pub struct SectionProps { /// Distance from the page edge to the header / footer. pub header: f32, pub footer: f32, + /// The space kept between a header and the body, and between the body and a footer, points + /// (ODF's header and footer spacing; Word keeps none). The margin already holds the band and + /// its spacing, so it only moves the body once content and spacing reach past the margin. + #[serde(skip_serializing_if = "is_zero")] + pub header_gap: f32, + #[serde(skip_serializing_if = "is_zero")] + pub footer_gap: f32, pub gutter: f32, /// Mirror margins (inside/outside) — set from the document settings. pub columns: Columns, @@ -772,6 +779,11 @@ pub struct SectionProps { pub footnote_pr: NoteProps, #[serde(skip_serializing_if = "NoteProps::is_empty")] pub endnote_pr: NoteProps, + /// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`): + /// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at + /// the end of the document, `Some(false)` never. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub balance_columns: Option, } impl Default for SectionProps { @@ -788,6 +800,8 @@ impl Default for SectionProps { margin_right: 72.0, header: 36.0, footer: 36.0, + header_gap: 0.0, + footer_gap: 0.0, gutter: 0.0, columns: Columns::default(), headers: HeaderSet::default(), @@ -807,10 +821,15 @@ impl Default for SectionProps { doc_grid: None, footnote_pr: NoteProps::default(), endnote_pr: NoteProps::default(), + balance_columns: None, } } } +fn is_zero(v: &f32) -> bool { + *v == 0.0 +} + fn is_horizontal(d: &crate::props::TextDirection) -> bool { !d.is_turned() } diff --git a/crates/doc/src/settings.rs b/crates/doc/src/settings.rs index 3d8c341cea..3dd38116c7 100644 --- a/crates/doc/src/settings.rs +++ b/crates/doc/src/settings.rs @@ -46,6 +46,10 @@ pub struct CustomProp { /// Bookmark associated with this property (`linkTarget`), retained without evaluating it. #[serde(skip_serializing_if = "Option::is_none")] pub link_target: Option, + /// Declared in an ODF document as a user field (§7.4.7 `text:user-field-decl`) rather than + /// a document property: written back as one. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub odf_user_field: bool, } /// Document properties (File › Info). @@ -162,6 +166,23 @@ pub struct Settings { /// comments and tracked changes are saved without `w:date`. #[serde(skip_serializing_if = "std::ops::Not::not")] pub remove_date_time: bool, + /// The document was opened from ODF: the standard's rules that differ from Word's apply as + /// it is laid out. + #[serde(skip_serializing_if = "Option::is_none")] + pub odf: Option, +} + +/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a +/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what +/// the file says about laying it out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OdfLayout { + /// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's + /// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's + /// explicit stops are held from the margin either way; the default ones are counted from the + /// indent. + pub tabs_from_indent: bool, } /// A saved zoom: a percentage, or a fit mode (the percentage it last came to). @@ -307,6 +328,7 @@ impl Default for Settings { zoom: None, remove_personal_info: false, remove_date_time: false, + odf: None, } } } diff --git a/crates/doc/src/smart_art.rs b/crates/doc/src/smart_art.rs index 16ed949d06..51e7609626 100644 --- a/crates/doc/src/smart_art.rs +++ b/crates/doc/src/smart_art.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; +use crate::props::Rgb; + /// Most items in a SmartArt graphic. pub const MAX_ITEMS: usize = 100; /// Longest item text, characters. @@ -198,11 +200,14 @@ pub struct SmartArtItem { pub text: String, #[serde(default)] pub level: u8, + /// Fill of the item's shape in place of the style's colour. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, } impl SmartArtItem { pub fn new(text: impl Into, level: u8) -> SmartArtItem { - SmartArtItem { text: text.into(), level } + SmartArtItem { text: text.into(), level, color: None } } } @@ -257,6 +262,35 @@ impl SmartArtSpec { } } + /// Whether any item has its own colour. + pub fn has_custom_colors(&self) -> bool { + self.items.iter().any(|it| it.color.is_some()) + } + + /// Give every item the style's colour again. + pub fn clear_custom_colors(&mut self) { + for it in &mut self.items { + it.color = None; + } + } + + /// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of + /// its own (it is text inside another item's shape). + pub fn style_accent(&self, i: usize) -> Option { + let it = self.items.get(i)?; + let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count(); + match self.layout { + SmartArtLayout::Hierarchy => Some(self.colors.accent(i)), + SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)), + SmartArtLayout::Radial => { + let parents = self.parents(); + let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None)); + spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1)) + } + _ => (it.level == 0).then(|| self.colors.accent(tops())), + } + } + /// The graphic after `sanitize`. pub fn sanitized(mut self) -> SmartArtSpec { self.sanitize(); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index f9932bf38c..cbbd2f35ec 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -232,6 +232,40 @@ impl PartSet { } } +/// A cell's borders from the table style's parts it is in, later parts winning. Row parts give +/// their left/right edges at the table's sides and `inside_v` between cells; column parts their +/// top/bottom edges at the table's top and bottom and `between` between rows; corner cells their +/// four edges. +pub fn part_borders(parts: &[(TablePart, Borders)], (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool)) -> Borders { + let mut out = Borders::default(); + for (part, b) in parts { + let b = *b; + let mapped = if part.is_row() { + Borders { + top: b.top, + bottom: b.bottom, + left: if first_g { b.left } else { b.inside_v }, + right: if last_g { b.right } else { b.inside_v }, + between: None, + inside_v: None, + } + } else if part.is_column() { + Borders { + top: if ri == 0 { b.top } else { b.between }, + bottom: if ri + 1 >= nrows { b.bottom } else { b.between }, + left: b.left, + right: b.right, + between: None, + inside_v: None, + } + } else { + Borders { between: None, inside_v: None, ..b } + }; + out.overlay(&mapped); + } + out +} + /// What one table style part gives its cells. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index abdda89241..bb951aa672 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -71,10 +71,12 @@ pub struct Table { /// Hard limits that keep hostile input from exhausting memory. pub const MAX_ROWS: usize = 32_767; pub const MAX_COLS: usize = 63; -/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting -/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack -/// when it is saved, copied or dropped (#1596). -pub const MAX_TABLE_DEPTH: usize = 24; +/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a +/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or +/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level +/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its +/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24. +pub const MAX_TABLE_DEPTH: usize = 18; impl Table { /// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 12189ac5a4..cca055a5e5 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use std::sync::Arc; mod note_conversion; @@ -35,8 +36,8 @@ fn word_count_counts_each_chinese_and_japanese_character() { #[test] fn word_count_includes_numbered_list_labels_but_not_bullets() { let mut d = Document::new(); - let num_id = d.numbering.add_list(ListKind::Numbered); - let bul_id = d.numbering.add_list(ListKind::Bullet); + let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + let bul_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet); let mut p1 = Paragraph::with_text("Apple", CharProps::default()); p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 }); @@ -60,9 +61,9 @@ fn word_count_includes_numbered_list_labels_but_not_bullets() { #[test] fn word_count_includes_numbered_labels_with_nothing_suffix() { let mut d = Document::new(); - let num_id = d.numbering.add_list(ListKind::Numbered); + let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); let aid = d.numbering.num(num_id).expect("list num exists").abstract_id; - let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists"); + let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists"); a.levels[0].suffix = numbering::LevelSuffix::Nothing; let mut p1 = Paragraph::with_text("Apple", CharProps::default()); @@ -81,9 +82,9 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() { #[test] fn word_count_keeps_a_cjk_list_label_one_word() { let mut d = Document::new(); - let num_id = d.numbering.add_list(ListKind::Numbered); + let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); let aid = d.numbering.num(num_id).expect("list num exists").abstract_id; - let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists"); + let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists"); a.levels[0].format = section::NumFormat::ChineseCounting; a.levels[0].text = "第%1章".into(); a.levels[0].start = 12; @@ -187,7 +188,7 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() { 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; + let n = Arc::make_mut(&mut d.numbering); n.add_list(ListKind::Bullet); n.add_list(ListKind::Bullet); n.abstracts[1].id = u32::MAX; @@ -229,11 +230,11 @@ fn same_content_sees_every_change() { assert!(!d.same_content(&e), "the formatting changed"); // The styles changed. let mut e = d.clone(); - e.styles.get_mut("Normal").unwrap().chr.bold = Some(true); + Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true); assert!(!d.same_content(&e), "a style changed"); // The lists changed. let mut e = d.clone(); - e.numbering.add_list(ListKind::Numbered); + Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered); assert!(!d.same_content(&e), "a list was added"); // The settings changed. let mut e = d.clone(); @@ -452,9 +453,9 @@ fn box_para(story: u32, id: u32) -> Blocks { fn adopting_relinks_lists_in_deeply_nested_tables() { // As #1339: a list paragraph 20 tables down takes the list it is brought in with. let mut ours = Document::from_text("ours"); - let our_list = ours.numbering.add_list(numbering::ListKind::Bullet); + let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet); let mut theirs = Document::from_text("theirs"); - let list = theirs.numbering.add_list(numbering::ListKind::Bullet); + let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet); assert_eq!(list, our_list); let mut p = Paragraph::with_text("item", CharProps::default()); p.props.numbering = Some(props::NumRef { num: list, level: 0 }); diff --git a/crates/doc/src/work.rs b/crates/doc/src/work.rs new file mode 100644 index 0000000000..6cb4d69fd0 --- /dev/null +++ b/crates/doc/src/work.rs @@ -0,0 +1,28 @@ +//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the +//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine +//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates' +//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the +//! count is nothing. + +#[cfg(any(test, feature = "test-support"))] +thread_local! { + /// The steps counted on this thread. + static STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Count `n` steps of a walk (nothing outside tests). +#[inline(always)] +pub(crate) fn steps(n: usize) { + #[cfg(any(test, feature = "test-support"))] + STEPS.with(|s| s.set(s.get().saturating_add(n as u64))); + #[cfg(not(any(test, feature = "test-support")))] + let _ = n; +} + +/// The steps `f` takes. +#[cfg(any(test, feature = "test-support"))] +pub fn count(f: impl FnOnce()) -> u64 { + let before = STEPS.with(std::cell::Cell::get); + f(); + STEPS.with(std::cell::Cell::get).saturating_sub(before) +} diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs index f5c02699f3..4fe3b62a6a 100644 --- a/crates/docbin/src/drawing.rs +++ b/crates/docbin/src/drawing.rs @@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) - let p = &sp.props; if let Some(pib) = p.pib { let media = pics.bse(pib, b.bstore, b.delay)?; - return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None }); + return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None }); } let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202); let mut kind = match sp.spt { diff --git a/crates/docbin/src/fmt.rs b/crates/docbin/src/fmt.rs index af289d318e..a5d85012c8 100644 --- a/crates/docbin/src/fmt.rs +++ b/crates/docbin/src/fmt.rs @@ -109,12 +109,13 @@ pub(crate) fn ico(i: u8) -> Option { ICO.get(i as usize).copied() } -/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model -/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX -/// reader maps them. +/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType; 0x02, thick, is in +/// the BRC of Microsoft's Word 97-2007 specification). Types the model doesn't carry (the gap +/// variants, 3-D, inset/outset) draw as single lines, as the DOCX reader maps them. fn brc_style(t: u8) -> BorderStyle { match t { 0x00 => BorderStyle::None, + 0x02 => BorderStyle::Thick, 0x03 => BorderStyle::Double, 0x06 => BorderStyle::Dotted, 0x07 | 0x16 => BorderStyle::Dashed, @@ -125,15 +126,15 @@ fn brc_style(t: u8) -> BorderStyle { } } -/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2; -/// 1-point steps for page borders, which never reach a paragraph property). +/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz` +/// reads (1-point steps for page borders, which never reach a paragraph property). fn brc_width(t: u8, dpt: u8) -> f32 { - if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 } + if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) } } /// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a /// truncated operand is ignored. -fn brc80(op: &[u8]) -> Option { +pub(crate) fn brc80(op: &[u8]) -> Option { let [dpt, ty, ico_index, tail, ..] = op else { return None }; if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] { return Some(Border::default()); diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 348e18694c..43f2e8d65e 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -25,7 +25,9 @@ mod stsh; mod table; use std::io::{Read, Seek}; +use std::sync::Arc; +use wordcraft_doc::link::hyperlink_target; use wordcraft_doc::para::InlineObject; use wordcraft_doc::para::NoteKind; use wordcraft_doc::para::Paragraph; @@ -248,9 +250,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result) -> Result = std::collections::HashMap::new(); + for (i, p) in paras.iter().enumerate() { + ending.entry(p.end_cp).or_insert(i); + } let last = sects.last().map(|s| s.props.clone()); for (si, sec) in sects.iter().enumerate() { let mut props = sec.props.clone(); @@ -374,11 +381,8 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic alt, float: Default::default(), crop: p.crop, + fx: Default::default(), ole: None, }); } @@ -720,85 +725,6 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) { 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 -/// (`#bookmark` for internal links); `None` when not a hyperlink. -fn hyperlink_target(instr: &str) -> Option { - let t = instr.trim(); - let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) { - t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim() - } else { - return None; - }; - if rest.is_empty() { - return None; - } - - let mut url: Option = None; - let mut local: Option = None; - let mut switch: Option<&str> = None; - - let mut i = 0; - let bytes = rest.as_bytes(); - while i < bytes.len() { - while i < bytes.len() && bytes[i].is_ascii_whitespace() { - i += 1; - } - if i >= bytes.len() { - break; - } - if bytes[i] == b'\\' { - let start = i; - i += 1; - while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') { - i += 1; - } - switch = Some(rest.get(start..i).unwrap_or("")); - continue; - } - let arg = if bytes[i] == b'"' { - i += 1; - let start = i; - while i < bytes.len() && bytes[i] != b'"' { - i += 1; - } - let val = rest.get(start..i).unwrap_or(""); - if i < bytes.len() && bytes[i] == b'"' { - i += 1; - } - val.to_string() - } else { - let start = i; - while i < bytes.len() && !bytes[i].is_ascii_whitespace() { - i += 1; - } - rest.get(start..i).unwrap_or("").to_string() - }; - let clean_arg = arg.replace("%22", "\""); - match switch.take() { - Some("\\l") => local = Some(clean_arg), - Some(_) => {} - None => { - if url.is_none() { - url = Some(clean_arg); - } - } - } - } - - match (url, local) { - (Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => { - let frag = l.trim_start_matches('#'); - if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) } - } - (Some(u), _) if !u.is_empty() => Some(u), - (None, Some(l)) if !l.is_empty() => { - let frag = l.trim_start_matches('#'); - Some(format!("#{frag}")) - } - _ => None, - } -} - /// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream. fn pic_location(chpx: &[u8]) -> Option { for prl in sprm::iter(chpx) { diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs index f9048c5f3e..3b0daf6395 100644 --- a/crates/docbin/src/table.rs +++ b/crates/docbin/src/table.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use wordcraft_doc::Rgb; use wordcraft_doc::para::Paragraph; -use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; +use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; use wordcraft_doc::table::{Cell, Row, Table}; use wordcraft_doc::{Block, Blocks, para_block}; @@ -101,6 +101,12 @@ impl RowInfo { self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w) } + /// How many per-cell overrides the row keeps, for tests. + #[cfg(test)] + pub(crate) fn overrides(&self) -> usize { + self.vmerge.len() + self.shading.len() + self.widths.len() + self.width_pcts.len() + self.valign.len() + self.merges.len() + } + /// The width percentage cell `i` ends up with, for tests. #[cfg(test)] pub(crate) fn width_pct_of(&self, i: u8) -> Option { @@ -108,6 +114,15 @@ impl RowInfo { } } +/// Set cell `c`'s override to `v`, replacing an earlier one: one slot per cell, however often +/// a PAPX repeats the sprm, so a shared PAPX can't pile up copies on every paragraph mark. +fn set(slots: &mut Vec<(u8, T)>, c: u8, v: T) { + match slots.iter_mut().find(|(k, _)| *k == c) { + Some(slot) => slot.1 = v, + None => slots.push((c, v)), + } +} + pub(crate) fn decode(papx: &[u8]) -> RowInfo { let mut r = RowInfo::default(); let mut gap_half: Option = None; @@ -144,8 +159,10 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { T_DXA_GAP_HALF => gap_half = s16(&prl), T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand), T_MERGE => { + // Rows apply only the first MAX_COLS merges. if let [a, b, ..] = prl.operand && b > a + && r.merges.len() < MAX_COLS { r.merges.push((*a, *b)); } @@ -158,7 +175,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { 1 => VMerge::Continue, _ => VMerge::None, }; - if m != VMerge::None { + // The first override for a cell wins. + if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) { r.vmerge.push((*itc, m)); } } @@ -168,7 +186,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { && let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[]) { for c in *a..(*b).min(MAX_COLS as u8) { - r.shading.push((c, Some(rgb))); + set(&mut r.shading, c, Some(rgb)); } } } @@ -176,7 +194,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { let cells = prl.operand.get(1..).unwrap_or(&[]); for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() { if let Some(rgb) = shd80_rgb(b) { - r.shading.push((i as u8, Some(rgb))); + set(&mut r.shading, i as u8, Some(rgb)); } } } @@ -196,7 +214,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) { continue; } - r.shading.push(((first + i) as u8, shd_rgb(b))); + set(&mut r.shading, (first + i) as u8, shd_rgb(b)); } } T_CELL_WIDTH => { @@ -207,12 +225,12 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { if *fts == 3 { let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0); for c in *a..(*b).min(MAX_COLS as u8) { - r.widths.push((c, w)); + set(&mut r.widths, c, w); } } else if *fts == 2 { let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0); for c in *a..(*b).min(MAX_COLS as u8) { - r.width_pcts.push((c, pct)); + set(&mut r.width_pcts, c, pct); } } } @@ -224,8 +242,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { 2 => VAlign::Bottom, _ => VAlign::Top, }; - for c in *a..*b { - r.valign.push((c, v)); + for c in *a..(*b).min(MAX_COLS as u8) { + set(&mut r.valign, c, v); } } } @@ -440,32 +458,12 @@ fn def_table_edges(operand: &[u8]) -> Vec { } /// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top, -/// left, bottom, right, inside-horizontal, inside-vertical. +/// left, bottom, right, inside-horizontal, inside-vertical, read as paragraph borders are. fn table_borders(operand: &[u8]) -> Option { - let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80); + let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(fmt::brc80); Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) }) } -/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte. -fn brc80(b: &[u8]) -> Option { - let [w, ty, ico, flags] = b else { return None }; - let style = match ty { - 0x00 => BorderStyle::None, - 0x02 | 0x15 => BorderStyle::Thick, - 0x03 => BorderStyle::Double, - 0x04 | 0x05 => BorderStyle::Dotted, - 0x06 | 0x07 => BorderStyle::Dashed, - 0x08 | 0x09 => BorderStyle::DotDash, - 0x0A | 0x18 => BorderStyle::Wave, - 0x11 => BorderStyle::Triple, - _ => BorderStyle::Single, - }; - if *ty == 0 && *w == 0 { - return None; - } - Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 }) -} - /// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns /// show the foreground; clear (0) and the other patterns show the background (Word writes a /// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill. diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index bc8c27fe86..b251cadbb5 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1018,6 +1018,32 @@ fn two_sections_box_the_first() { assert!(!doc.last_section.title_page); } +#[test] +fn sections_ending_off_a_paragraph_mark_attach_nowhere() { + // Hostile section ends that miss every paragraph mark are skipped; the one that ends a + // paragraph still boxes it. Paragraphs are found by their end, not scanned per section. + let mut sections: Vec<(u32, Vec)> = (1..6).map(|cp| (cp, Vec::new())).collect(); + sections.push((6, grpprl(&[(0x300A, &[0x01])]))); + sections.push((12, Vec::new())); + let spec = StructSpec { + text: "first\rsecond\r", + header_stories: vec![], + sections, + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect(); + assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page); + assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere"); +} + #[test] fn header_story_becomes_part() { let spec = StructSpec { @@ -1183,6 +1209,53 @@ fn table_merges_borders_shading() { assert_eq!(top.width, 0.5); } +/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border. +#[test] +fn table_borders_read_border_types_as_paragraphs_do() { + use wordcraft_doc::props::BorderStyle as S; + let text = "a\u{7}\u{7}tail\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); + let row_end = { + let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat(); + v.extend_from_slice(&0xD605u16.to_le_bytes()); + v.push(0x18); + // Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero. + for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] { + v.extend_from_slice(&brc); + } + v + }; + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); + let b = tbl.props.borders.expect("table borders"); + let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width))); + let want = [ + (S::Dotted, true, 1.0), + (S::Triple, true, 1.0), + (S::Wave, true, 1.0), + (S::None, false, 0.0), + (S::Single, true, 12.0), + (S::None, false, 0.25), + ]; + assert_eq!(seen, want.map(Some)); + // A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are. + let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph"); + assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0)); +} + // --------------------------------------------------------------------------------------------- // D3: lists (PlfLst + PlfLfo) and the combined document. @@ -1443,6 +1516,16 @@ fn hyperlink_target_needs_the_whole_field_name() { assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into())); } +/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX. +#[test] +fn hyperlink_target_keeps_the_address_after_a_bare_switch() { + assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into())); +} + #[test] fn bookmarks_become_objects() { let text = "Hello world\r"; @@ -2375,6 +2458,23 @@ fn second_table_gets_its_own_grid() { assert_eq!(tables[1].props.indent, None, "indent reset"); } +#[test] +fn repeated_cell_sprms_keep_one_override_per_cell() { + // A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one + // slot per cell, the last value winning, instead of a copy per repetition. + let mut sprms = Vec::new(); + for w in 0..200u16 { + let [w0, w1] = (w * 20).to_le_bytes(); + sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1])); + sprms.push(sprm_cb(0xD62C, &[0, 255, 1])); + sprms.push([0x24, 0x56, 0, 2].to_vec()); + } + let row = crate::table::decode(&sprms.concat()); + assert_eq!(row.width_of(0), Some(199.0), "the last width wins"); + assert_eq!(row.width_of(62), Some(199.0)); + assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides()); +} + #[test] fn sprm_t_cell_width_twips_and_percentage() { // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635): @@ -2439,17 +2539,21 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() { } /// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80 -/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). +/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick +/// border (type 0x02) stays thick in both, as tables read it too. #[test] fn paragraph_borders_read_from_both_forms() { + use wordcraft_doc::props::BorderStyle as S; let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text + let thick80 = [8u8, 0x02, 0x06, 0x01]; let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2 + let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00]; let text = "boxed\r\r"; let spec = StructSpec { text, papx_entries: vec![ - (0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])), - (6, 0, grpprl(&[(0xC64E, &brc)])), + (0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])), + (6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])), ], ..structured_spec(text, text.chars().count() as u32) }; @@ -2457,13 +2561,16 @@ fn paragraph_borders_read_from_both_forms() { let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph"); let b = p0.props.borders.expect("borders"); let top = b.top.expect("top border"); - assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0)); + assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0)); assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0))); - assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some()); + assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick"); + assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some()); let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph"); - let top = p1.props.borders.expect("borders").top.expect("top border"); - assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0)); + let b = p1.props.borders.expect("borders"); + let top = b.top.expect("top border"); + assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0)); assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0))); + assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick"); } /// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb` diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index b754206320..ae9ec9e3fe 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -391,6 +391,7 @@ fn a_floating_copy_is_charged_its_alt_text() { alt, float: Default::default(), crop: Default::default(), + fx: Default::default(), ole: None, }; let bare = crate::object_cost(&image(String::new())); diff --git a/crates/docx/src/custom.rs b/crates/docx/src/custom.rs index aa49c45879..be329d9ec7 100644 --- a/crates/docx/src/custom.rs +++ b/crates/docx/src/custom.rs @@ -25,11 +25,23 @@ pub fn read(root: &El) -> Vec { let Some(v) = p.els().next() else { continue }; let kind = v.local(); let prop = if SIMPLE.contains(&kind) { - CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) } + CustomProp { + name: name.to_string(), + kind: kind.to_string(), + value: v.text(), + link_target: p.attr("linkTarget").map(str::to_string), + odf_user_field: false, + } } else { let mut xml = String::new(); el_xml(v, &mut xml, 0); - CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) } + CustomProp { + name: name.to_string(), + kind: "raw".into(), + value: xml, + link_target: p.attr("linkTarget").map(str::to_string), + odf_user_field: false, + } }; out.push(prop); } diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index ed5ccf0d03..5aa683f9c0 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; use wordcraft_doc::para::cap_alt; use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef, - ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, - VertAlign, + ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline, + VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{ Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat, @@ -172,8 +172,9 @@ impl PropCtx { } /// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour - /// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as - /// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one. + /// scheme mapping, with its `tint` or else its `shade` (hex bytes) applied to the HSL + /// luminance as Word does: given both, Word applies the tint alone (MS-OI29500 17.3.2.6 b, + /// 17.4.32 a). When given it supersedes the fixed colour; `None` leaves the fixed one. fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option { let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i); let slot = match e.attr(attr)? { @@ -199,8 +200,7 @@ impl PropCtx { let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0); if let Some(t) = byte(tint) { c = adjust_lum(c, |l| l * t + (1.0 - t)); - } - if let Some(s) = byte(shade) { + } else if let Some(s) = byte(shade) { c = adjust_lum(c, |l| l * s); } Some(c) @@ -208,6 +208,10 @@ impl PropCtx { /// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour. pub fn shd_fill(&self, e: &El) -> Option { + // `nil` counts as no shading, whatever fill it carries (MS-OI29500 17.18.78). + if e.attr("w:val") == Some("nil") { + return None; + } if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") { return Some(c); } @@ -250,6 +254,7 @@ impl PropCtx { "w:cs" => c.cs = Some(on_off(k)), "w:caps" => c.caps = Some(on_off(k)), "w:smallCaps" => c.small_caps = Some(on_off(k)), + "w:em" => c.emphasis = Some(k.attr("w:val").map(wordcraft_doc::props::EmphasisMark::from_ooxml).unwrap_or_default()), "w:strike" => c.strike = Some(on_off(k)), "w:dstrike" => c.double_strike = Some(on_off(k)), "w:outline" => c.outline = Some(on_off(k)), @@ -282,7 +287,7 @@ impl PropCtx { c.underline_color = self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse)); } - "w:bdr" => c.border = Some(border(k)), + "w:bdr" => c.border = Some(self.border(k)), "w:shd" => c.shading = self.shd_fill(k), "w:vertAlign" => { c.vert_align = k.attr("w:val").map(|v| match v { @@ -338,6 +343,7 @@ impl PropCtx { "w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)), "w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)), "w:snapToGrid" => p.snap_to_grid = Some(on_off(k)), + "w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml), "w:framePr" => { if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") { p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8); @@ -361,7 +367,7 @@ impl PropCtx { (None, None) => {} } } - "w:pBdr" => p.borders = Some(borders(k)), + "w:pBdr" => p.borders = Some(self.borders(k)), "w:shd" => p.shading = self.shd_fill(k), "w:tabs" => { let tabs: Vec = k.children("w:tab").take(64).filter_map(tab_stop).collect(); @@ -476,29 +482,32 @@ fn shd_fill(e: &El) -> Option { None } -pub fn border(e: &El) -> Border { - let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single")); - let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0); - let color = e.attr("w:color").and_then(Rgb::parse); - let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0); - Border { style, width, color, space } -} +impl PropCtx { + /// One border (`CT_Border`); its theme colour supersedes `w:color`, as for text. + pub fn border(&self, e: &El) -> Border { + let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single")); + let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0); + let color = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| e.attr("w:color").and_then(Rgb::parse)); + let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0); + Border { style, width, color, space } + } -/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`. -pub fn borders(e: &El) -> Borders { - let mut b = Borders::default(); - for k in e.els() { - match k.local() { - "top" => b.top = Some(border(k)), - "left" | "start" => b.left = Some(border(k)), - "bottom" => b.bottom = Some(border(k)), - "right" | "end" => b.right = Some(border(k)), - "between" | "insideH" => b.between = Some(border(k)), - "insideV" => b.inside_v = Some(border(k)), - _ => {} + /// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`. + pub fn borders(&self, e: &El) -> Borders { + let mut b = Borders::default(); + for k in e.els() { + match k.local() { + "top" => b.top = Some(self.border(k)), + "left" | "start" => b.left = Some(self.border(k)), + "bottom" => b.bottom = Some(self.border(k)), + "right" | "end" => b.right = Some(self.border(k)), + "between" | "insideH" => b.between = Some(self.border(k)), + "insideV" => b.inside_v = Some(self.border(k)), + _ => {} + } } + b } - b } /// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right]. @@ -532,7 +541,7 @@ impl PropCtx { } "w:jc" => t.align = k.attr("w:val").map(align), "w:tblInd" => t.indent = tw(k, "w:w"), - "w:tblBorders" => t.borders = Some(borders(k)), + "w:tblBorders" => t.borders = Some(self.borders(k)), "w:shd" => t.shading = self.shd_fill(k), "w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"), "w:bidiVisual" => t.bidi_visual = on_off(k), @@ -638,7 +647,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) { "w:gridSpan" => c.span = k.attr("w:val").and_then(u32_of).unwrap_or(1).clamp(1, 63), "w:hMerge" => hcont = k.attr("w:val") != Some("restart"), "w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue }, - "w:tcBorders" => c.borders = Some(borders(k)), + "w:tcBorders" => c.borders = Some(pc.borders(k)), "w:shd" => c.shading = pc.shd_fill(k), "w:noWrap" => c.no_wrap = on_off(k), "w:hideMark" => c.hide_mark = on_off(k), @@ -675,7 +684,7 @@ pub struct HfRef { } /// `w:sectPr` (header/footer references are returned for the caller to resolve). -pub fn sectpr(e: &El) -> (SectionProps, Vec) { +pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec) { let mut s = SectionProps::default(); let mut refs = Vec::new(); for k in e.els() { @@ -780,7 +789,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { _ => PageVAlign::Top, } } - "w:pgBorders" => s.page_borders = Some(borders(k)), + "w:pgBorders" => s.page_borders = Some(pc.borders(k)), "w:docGrid" => { let mut g = DocGrid::default(); if let Some(t) = k.attr("w:type").and_then(DocGridType::from_ooxml) { diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index 13f6f3e153..d2f6358e30 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -31,7 +31,7 @@ impl Reader<'_> { let cap_font = first.runs.first().and_then(|r| r.props.font.clone()); let text_font = p.runs.first().and_then(|r| r.props.font.clone()); if cap_font.is_some() && cap_font != text_font { - for r in &mut first.runs { + for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) { r.props.font = text_font.clone(); } props.drop_cap_font = cap_font; @@ -289,10 +289,10 @@ impl Reader<'_> { } pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps { - let (mut sp, refs) = sectpr(s); + let (mut sp, refs) = sectpr(&self.pc, s); // §17.13.5.32: the section's properties before a tracked change (no header references). if let Some(ch) = s.child("w:sectPrChange") { - let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default(); + let old = ch.child("w:sectPr").map(|o| sectpr(&self.pc, o).0).unwrap_or_default(); sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); } for r in refs { diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index 22bcae2d52..3a69dcc4e3 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -263,7 +263,7 @@ impl Reader<'_> { } } } - Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None }) + Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None }) } /// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups diff --git a/crates/docx/src/read/story/fields.rs b/crates/docx/src/read/story/fields.rs index 1847d3e8ad..68b29f200e 100644 --- a/crates/docx/src/read/story/fields.rs +++ b/crates/docx/src/read/story/fields.rs @@ -1,4 +1,5 @@ use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx}; +use wordcraft_doc::link::hyperlink_target; /// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word /// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text @@ -24,69 +25,6 @@ fn is_range_instr(instr: &str) -> bool { crate::read::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN")) } -/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or -/// `#bookmark`. None for any other field, or one with no target. -fn hyperlink_target(instr: &str) -> Option { - // Only a `HYPERLINK` code is split up (a field's code may run to megabytes). - if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) { - return None; - } - let args = field_args(instr); - let mut args = args.iter(); - if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) { - return None; - } - let (mut url, mut anchor) = (None, None); - while let Some((a, quoted)) = args.next() { - if *quoted || !a.starts_with('\\') { - url = url.or(Some(a.as_str())); - continue; - } - // `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none. - let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten(); - if a.eq_ignore_ascii_case("\\l") { - anchor = value.map(|(v, _)| v.as_str()); - } - } - let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty())); - match (url, anchor) { - (Some(u), Some(a)) => Some(format!("{u}#{a}")), - (Some(u), None) => Some(u.to_string()), - (None, Some(a)) => Some(format!("#{a}")), - (None, None) => None, - } -} - -/// A field code's words: the field's name, its switches and its arguments, each with whether -/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash. -fn field_args(instr: &str) -> Vec<(String, bool)> { - let mut out = Vec::new(); - let mut chars = instr.chars().peekable(); - while let Some(c) = chars.next() { - if c.is_whitespace() { - continue; - } - let mut word = String::new(); - if c == '"' { - while let Some(c) = chars.next() { - match c { - '"' => break, - '\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()), - _ => word.push(c), - } - } - out.push((word, true)); - } else { - word.push(c); - while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') { - word.push(c); - } - out.push((word, false)); - } - } - out -} - impl Reader<'_> { // ---- fields ---- diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index ebb2353b6a..b625b8d4f0 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -134,7 +134,10 @@ impl Reader<'_> { let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?; let media = self.media_for(rels, id)?; let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string(); - return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None }); + // Brightness and contrast (Word's picture watermark "Washout" sets them, #1711). + let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number); + let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0)); + return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None }); } // The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in // the same picture: what the shape leaves out, it takes from there. diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index cf9bccf51b..878edddf98 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -2,6 +2,7 @@ use super::{ AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts, VAlign, flag, int, props, tw, u32_of, }; +use std::sync::Arc; /// Built-in style names Word stores in lower case, and how the UI shows them. pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[ @@ -186,7 +187,7 @@ impl Reader<'_> { if !sheet.styles.iter().any(|s| s.quick) { sheet.add_default_gallery(); } - self.doc.styles = sheet; + self.doc.styles = Arc::new(sheet); Ok(()) } @@ -196,7 +197,7 @@ impl Reader<'_> { let mut t = TableStyleParts::default(); if let Some(tp) = s.child("w:tblPr") { if let Some(b) = tp.child("w:tblBorders") { - t.borders = Some(props::borders(b)); + t.borders = Some(self.pc.borders(b)); } t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e)); t.cell_margins = tp.child("w:tblCellMar").map(props::margins); @@ -210,7 +211,7 @@ impl Reader<'_> { 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), + borders: tc.and_then(|p| p.child("w:tcBorders")).map(|b| self.pc.borders(b)), 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, @@ -229,7 +230,7 @@ impl Reader<'_> { t.fill = f.fill; } if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) { - t.borders = Some(props::borders(b)); + t.borders = Some(self.pc.borders(b)); } if f.v_align.is_some() { t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() }); @@ -298,8 +299,8 @@ impl Reader<'_> { a.levels = levels; } } - self.doc.numbering.abstracts = abstracts; - self.doc.numbering.nums = nums; + Arc::make_mut(&mut self.doc.numbering).abstracts = abstracts; + Arc::make_mut(&mut self.doc.numbering).nums = nums; Ok(()) } diff --git a/crates/docx/src/smart_art.rs b/crates/docx/src/smart_art.rs index 9378192a87..eedf6f459d 100644 --- a/crates/docx/src/smart_art.rs +++ b/crates/docx/src/smart_art.rs @@ -86,6 +86,8 @@ enum Paint { None, /// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set. Accent(usize, Option), + /// A colour of its own, lightened to `tint` when set. + Rgb(Rgb, Option), /// The theme's light background colour. Light, } @@ -150,6 +152,22 @@ impl Sh { } } + /// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines + /// keep the theme's background colour so adjacent nodes remain separated. + fn recolor(&mut self, spec: &SmartArtSpec, i: usize) { + let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return }; + for p in [&mut self.fill, &mut self.line] { + if let Paint::Accent(_, tint) = *p { + *p = Paint::Rgb(c, tint); + } + } + } + + fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh { + self.recolor(spec, i); + self + } + fn with_text(mut self, text: Vec<(String, bool)>) -> Sh { self.text = text; self @@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let in_row = if r + 1 == rows { n - r * cols } else { cols }; let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0; let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh]; - out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true))); + out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true))); } out } @@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1)); a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000)); a.line = Paint::None; + if let Some(&i) = tops.get(k - 1) { + a.recolor(spec, i); + } out.push(a); } for (k, &i) in tops.iter().enumerate() { let b = [k as f32 * (nw + gap), y, nw, nh]; - out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true))); + out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true))); } out } @@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { a.rot = (mid + 90.0).rem_euclid(360.0); a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000)); a.line = Paint::None; + if let Some(&i) = tops.get(k) { + a.recolor(spec, i); + } out.push(a); } } @@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { for (k, &i) in tops.iter().enumerate() { let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) }; let b = [x - d / 2.0, y - d / 2.0, d, d]; - out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); + out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); } out } @@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let (px, pb) = (cx(i), top(i) + nh); let ct = ks.first().map_or(pb, |&k| top(k)); let mid = (pb + ct) / 2.0; - out.push(Sh::line(px, pb, px, mid, accent)); + out.push(Sh::line(px, pb, px, mid, accent).item(spec, i)); let xs: Vec = ks.iter().map(|&k| cx(k)).collect(); let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x))); if hi - lo > 0.5 { - out.push(Sh::line(lo, mid, hi, mid, accent)); + out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i)); } for x in xs { - out.push(Sh::line(x, mid, x, ct, accent)); + out.push(Sh::line(x, mid, x, ct, accent).item(spec, i)); } } for i in 0..n { let b = [cx(i) - nw / 2.0, top(i), nw, nh]; - out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false))); + out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false))); } out } @@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let mid_w = (top_w + bot_w) / 2.0 * 0.8; s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84]) }; - out.push(sh.with_text(paras(spec, i, true))); + out.push(sh.item(spec, i).with_text(paras(spec, i, true))); } out } @@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { if n == 0 { let d = m * 0.5; let b = [cx - d / 2.0, cy - d / 2.0, d, d]; - out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET))); + out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET))); return out; } let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() }; @@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { let at = |k: usize| polar(cx, cy, r, angle(k, n)); for k in 0..n { let (x, y) = at(k); - out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0))); + out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0)); } let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc]; - out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET))); + out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET))); for (k, &i) in spokes.iter().enumerate() { let (x, y) = at(k); let b = [x - ds / 2.0, y - ds / 2.0, ds, ds]; - out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); + out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET))); } out } @@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { for (k, &i) in tops.iter().enumerate() { let (r, c) = (k / cols, k % cols); let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap]; - out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1))); + out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1))); } out } @@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec { (x, y, deg) }; let mut out = Vec::new(); - for (j, _) in tops.iter().enumerate() { + for (j, &i) in tops.iter().enumerate() { let (x, y, _) = place(j); let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j)); s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000)); s.alpha = Some(60_000); s.line = Paint::None; - out.push(s); + out.push(s.item(spec, i)); } // Every outline over every fill, so each circle shows whole. - for (j, _) in tops.iter().enumerate() { + for (j, &i) in tops.iter().enumerate() { let (x, y, _) = place(j); let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j)); s.fill = Paint::None; s.line = Paint::Accent(spec.colors.accent(j), None); s.line_w = 1.5; - out.push(s); + out.push(s.item(spec, i)); } for (j, &i) in tops.iter().enumerate() { let (x, y, deg) = place(j); @@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option) { w.empty("a:schemeClr", &[("val", "lt1")]); w.close("a:solidFill"); } + Paint::Rgb(c, tint) => { + w.open("a:solidFill", &[]); + let val = c.hex(); + if tint.is_none() && alpha.is_none() { + w.empty("a:srgbClr", &[("val", &val)]); + } else { + w.open("a:srgbClr", &[("val", &val)]); + if let Some(t) = tint { + w.empty("a:tint", &[("val", &t.to_string())]); + } + if let Some(a) = alpha { + w.empty("a:alpha", &[("val", &a.to_string())]); + } + w.close("a:srgbClr"); + } + w.close("a:solidFill"); + } Paint::Accent(n, tint) => { w.open("a:solidFill", &[]); let val = format!("accent{}", n.clamp(1, 6)); @@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String { let (node, cxn, par, sib) = item_ids(i); w.open("dgm:pt", &[("modelId", &node)]); w.empty("dgm:prSet", &[]); - w.empty("dgm:spPr", &[]); + match it.color { + Some(c) => { + w.open("dgm:spPr", &[]); + w.open("a:solidFill", &[]); + w.empty("a:srgbClr", &[("val", &c.hex())]); + w.close("a:solidFill"); + w.close("dgm:spPr"); + } + None => w.empty("dgm:spPr", &[]), + } w.open("dgm:t", &[]); w.empty("a:bodyPr", &[]); w.empty("a:lstStyle", &[]); @@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option { .zip(&levels) .map(|(p, l)| { let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect(); - Some(SmartArtItem { text, level: (*l)? }) + let color = + p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?)); + Some(SmartArtItem { text, level: (*l)?, color }) }) .collect::>>()?; let spec = SmartArtSpec { layout, items, colors }.sanitized(); @@ -1138,6 +1190,18 @@ mod tests { } } + #[test] + fn an_items_own_colour_is_saved_drawn_and_read_back() { + let mut spec = SmartArtSpec::sample(SmartArtLayout::Process); + spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56)); + let spec = spec.sanitized(); + assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec)); + let drawing = drawing_xml(&spec, 400.0, 250.0); + assert_eq!(drawing.matches(" Option { v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64) } +/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix +/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite. +pub fn vml_number(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() || t.len() > 40 { + return None; + } + let v = match t.strip_suffix('f') { + Some(fixed) => fixed.trim().parse::().ok()? / 65_536.0, + None => t.parse::().ok()?, + }; + v.is_finite().then_some(v) +} + +/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and +/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766). +pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx { + let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32); + let lum = blip.child("a:lum"); + let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000); + wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt) +} + pub fn u32_of(s: &str) -> Option { int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32) } diff --git a/crates/docx/src/wordml2003_write.rs b/crates/docx/src/wordml2003_write.rs index cf99b291d7..bc14277f9c 100644 --- a/crates/docx/src/wordml2003_write.rs +++ b/crates/docx/src/wordml2003_write.rs @@ -317,7 +317,8 @@ impl<'a> Conv<'a> { /// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped. fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec { - let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() }; + let Some(blip) = drawing.find("a:blip") else { return Vec::new() }; + let Some(id) = blip.attr("r:embed") else { return Vec::new() }; let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() }; let Some(src) = self.bin_for(&target) else { return Vec::new() }; let pt = |k: &str| { @@ -329,7 +330,19 @@ impl<'a> Conv<'a> { let shape_id = format!("_x0000_i{}", self.next_shape); self.next_shape = self.next_shape.saturating_add(1); let mut kids: Vec = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect(); - kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt))); + let mut shape = self.shape_frame(&shape_id, &style, &src, &alt); + // Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711). + // `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped. + let fx = crate::units::blip_fx(blip); + if fx.changes_pixels() + && let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata")) + { + let (gain, black) = fx.to_vml(); + let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64); + data.attrs.push(("gain".into(), fixed(gain))); + data.attrs.push(("blacklevel".into(), fixed(black))); + } + kids.push(Node::El(shape)); vec![Node::El(plain("w:pict", kids))] } diff --git a/crates/docx/src/write/parts.rs b/crates/docx/src/write/parts.rs index 39c2e58d24..f35a36fc91 100644 --- a/crates/docx/src/write/parts.rs +++ b/crates/docx/src/write/parts.rs @@ -294,8 +294,10 @@ fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels, wr.used_media.insert(p.media.clone()); w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]); let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)]; - if !p.fill_type.is_empty() { - fa.push(("type", &p.fill_type)); + // VML has no centred fill (an ODT page background's): stretched to the page. + let kind = if p.fill_type.eq_ignore_ascii_case("center") { "frame" } else { p.fill_type.as_str() }; + if !kind.is_empty() { + fa.push(("type", kind)); } if !p.title.is_empty() { fa.push(("o:title", &p.title)); diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 57400cd059..839950701e 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -108,6 +108,10 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { } toggle(w, "w:rtl", c.rtl); toggle(w, "w:cs", c.cs); + // Emphasis mark (§17.3.2.12), after `w:cs` and before `w:lang`. + if let Some(m) = c.emphasis { + w.val("w:em", m.ooxml()); + } let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3); if let Some(l) = &c.lang { lang_attrs.push(("w:val", l)); @@ -283,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha if let Some(a) = p.align { w.val("w:jc", align_val(a)); } + if let Some(t) = p.text_alignment { + w.val("w:textAlignment", t.ooxml()); + } if let Some(l) = p.outline_level { w.val("w:outlineLvl", &n(l.min(9) as i64)); } diff --git a/crates/docx/src/write/story/drawing.rs b/crates/docx/src/write/story/drawing.rs index 078aa9d3e1..f82a736fab 100644 --- a/crates/docx/src/write/story/drawing.rs +++ b/crates/docx/src/write/story/drawing.rs @@ -15,7 +15,7 @@ pub(super) struct DrawingInfo<'a> { impl Writer<'_> { /// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0). pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) { - let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, .. } = o else { return }; + let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return }; let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); self.used_media.insert(media.clone()); w.open("pic:pic", &[]); @@ -26,7 +26,21 @@ impl Writer<'_> { w.close("pic:cNvPicPr"); w.close("pic:nvPicPr"); w.open("pic:blipFill", &[]); - w.empty("a:blip", &[("r:embed", &rid)]); + // Brightness, contrast and transparency (#1711, #1766) as `a:blip` effects (ECMA-376 + // §20.1.8.13 CT_Blip: an unbounded choice of effects, in any order). + let fx = fx.clamped(); + if fx.is_identity() { + w.empty("a:blip", &[("r:embed", &rid)]); + } else { + w.open("a:blip", &[("r:embed", &rid)]); + if fx.transparency != 0 { + w.empty("a:alphaModFix", &[("amt", &n(i64::from(100_000 - fx.transparency)))]); + } + if fx.bright != 0 || fx.contrast != 0 { + w.empty("a:lum", &[("bright", &n(i64::from(fx.bright))), ("contrast", &n(i64::from(fx.contrast)))]); + } + w.close("a:blip"); + } if crop.iter().any(|c| *c > 0.0) { let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64); w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]); diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index cee8a045c1..ef6c338216 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -249,7 +249,7 @@ impl Writer<'_> { fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) { let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return }; // An OLE object read from a file: the object itself, at its current size. - if let Some(src) = ole.as_deref() + if let Some(src) = ole.as_deref().filter(|e| !e.is_odf_object()) && let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str()))) { self.rev_open(w, props); diff --git a/crates/docx/tests/it/alt_chunks.rs b/crates/docx/tests/it/alt_chunks.rs index dd2dddbdee..af2404329f 100644 --- a/crates/docx/tests/it/alt_chunks.rs +++ b/crates/docx/tests/it/alt_chunks.rs @@ -317,7 +317,16 @@ fn a_chunks_pictures_come_with_it() { let mut d = Document::new(); let key = d.add_media(vec![1, 2, 3], "png"); let mut para = wordcraft_doc::Paragraph::new(); - let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + let image = InlineObject::Image { + media: key, + w: 10.0, + h: 10.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + fx: Default::default(), + }; para.insert_object(0, image, &Default::default()).unwrap(); d.body = vec![std::sync::Arc::new(Block::Para(para))]; Some(d) diff --git a/crates/docx/tests/it/bidi_runs.rs b/crates/docx/tests/it/bidi_runs.rs index 067918f26b..96d7b05e0b 100644 --- a/crates/docx/tests/it/bidi_runs.rs +++ b/crates/docx/tests/it/bidi_runs.rs @@ -7,6 +7,7 @@ //! in a right-to-left paragraph. use std::io::Read; +use std::sync::Arc; use wordcraft_doc::props::CharProps; use wordcraft_doc::{Document, Paragraph, para_block}; @@ -189,6 +190,6 @@ fn a_language_the_run_already_has_is_kept() { #[test] fn an_inherited_bidi_language_is_not_repeated() { let mut d = doc_of("سلام WordCraft دنیا", true, arial()); - d.styles.default_chr.lang_bidi = Some("fa-IR".into()); + Arc::make_mut(&mut d.styles).default_chr.lang_bidi = Some("fa-IR".into()); assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]); } diff --git a/crates/docx/tests/it/border_lines.rs b/crates/docx/tests/it/border_lines.rs new file mode 100644 index 0000000000..3033849ce9 --- /dev/null +++ b/crates/docx/tests/it/border_lines.rs @@ -0,0 +1,76 @@ +//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to +//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and +//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec. + +use std::io::{Read, Write}; + +use wordcraft_doc::props::BorderStyle; + +const LINES: [(&str, BorderStyle); 9] = [ + ("thinThickSmallGap", BorderStyle::ThinThickSmallGap), + ("thickThinSmallGap", BorderStyle::ThickThinSmallGap), + ("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap), + ("thinThickMediumGap", BorderStyle::ThinThickMediumGap), + ("thickThinMediumGap", BorderStyle::ThickThinMediumGap), + ("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap), + ("thinThickLargeGap", BorderStyle::ThinThickLargeGap), + ("thickThinLargeGap", BorderStyle::ThickThinLargeGap), + ("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap), +]; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + 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 side(name: &str, val: &str) -> String { + format!(r#""#) +} + +#[test] +fn compound_paragraph_borders_are_written_back_as_read() { + let body: String = LINES + .iter() + .map(|(val, _)| { + format!(r#"{}{}{val}"#, side("top", val), side("bottom", val)) + }) + .collect(); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let got: Vec> = + d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect(); + assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::>()); + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + for (val, _) in LINES { + assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}"); + } + assert!(!xml.contains(r#"w:val="single""#), "{xml}"); +} + +#[test] +fn compound_cell_and_page_borders_are_written_back_as_read() { + let body = format!( + r#"{}{}Cell{}"#, + side("top", "thickThinLargeGap"), + side("left", "thinThickSmallGap"), + side("right", "thinThickThinMediumGap") + ); + let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap()); + for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] { + assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}"); + } +} diff --git a/crates/docx/tests/it/default_size.rs b/crates/docx/tests/it/default_size.rs index b6c4c6d143..65d5a1f9fa 100644 --- a/crates/docx/tests/it/default_size.rs +++ b/crates/docx/tests/it/default_size.rs @@ -2,6 +2,7 @@ //! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because //! the reader reads an omitted size as 10 pt. use std::io::{Read, Write}; +use std::sync::Arc; use wordcraft_doc::Document; use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE}; fn resolved(d: &Document) -> f32 { @@ -19,7 +20,7 @@ fn defaults(xml: &str) -> &str { #[test] fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() { let mut d = Document::new(); - d.styles.default_chr.size = None; + Arc::make_mut(&mut d.styles).default_chr.size = None; assert_eq!(resolved(&d), DEFAULT_FONT_SIZE); let bytes = wordcraft_docx::write(&d).unwrap(); assert!(defaults(&styles_xml(&bytes)).contains(r#""#)); @@ -33,7 +34,7 @@ fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() { fn an_explicit_default_size_is_written_as_it_is() { for size in [8.0, 10.0, 11.0, 12.5, 18.0] { let mut d = Document::new(); - d.styles.default_chr.size = Some(size); + Arc::make_mut(&mut d.styles).default_chr.size = Some(size); let bytes = wordcraft_docx::write(&d).unwrap(); assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size); } @@ -59,8 +60,8 @@ fn an_unset_default_size_does_not_take_a_styles_size() { // The written default is the resolver's own fallback, not what the Normal style resolves to, // so a style that sets a size keeps it and one that does not still falls back to the default. let mut d = Document::new(); - d.styles.default_chr.size = None; - if let Some(normal) = d.styles.get_mut("Normal") { + Arc::make_mut(&mut d.styles).default_chr.size = None; + if let Some(normal) = Arc::make_mut(&mut d.styles).get_mut("Normal") { normal.chr.size = Some(14.0); } let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); diff --git a/crates/docx/tests/it/drawing_payload.rs b/crates/docx/tests/it/drawing_payload.rs index 7b3d251f4e..06c245d0bf 100644 --- a/crates/docx/tests/it/drawing_payload.rs +++ b/crates/docx/tests/it/drawing_payload.rs @@ -22,6 +22,7 @@ fn picture() -> InlineObject { alt: String::new(), float: Default::default(), crop: [0.0; 4], + fx: Default::default(), ole: None, } } diff --git a/crates/docx/tests/it/emphasis_marks.rs b/crates/docx/tests/it/emphasis_marks.rs new file mode 100644 index 0000000000..25238d0684 --- /dev/null +++ b/crates/docx/tests/it/emphasis_marks.rs @@ -0,0 +1,48 @@ +//! Emphasis marks (`w:em`, ISO/IEC 29500-1 §17.3.2.12: Font › Emphasis mark) are read and +//! saved again, in their place in `w:rPr`. Fixtures are hand-written from the spec. + +use std::io::{Read, Write}; + +use wordcraft_doc::props::EmphasisMark; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, xml) in [("_rels/.rels", root.to_string()), ("word/document.xml", doc)] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(xml.as_bytes()).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn marks(d: &wordcraft_doc::Document) -> Vec> { + d.body[0].as_para().unwrap().runs.iter().map(|r| r.props.emphasis).collect() +} + +#[test] +fn emphasis_marks_are_read_and_saved() { + let run = |t: &str, em: &str| format!(r#"{em}{t}"#); + let body = format!( + "{}{}{}{}{}{}", + run("a", r#""#), + run("b", r#""#), + run("c", r#""#), + run("d", r#""#), + run("e", r#""#), + run("f", r#""#), + ); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + // An unknown mark is none (and the run with it joins the one before, now alike). + let want = [EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot, EmphasisMark::None].map(Some); + assert_eq!(marks(&d), want); + let saved = wordcraft_docx::write(&d).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap(); + let mut xml = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + // In its place: after w:cs, before w:lang. + assert!(xml.contains(r#""#); let mut d = wordcraft_docx::read(&docx("", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap(); assert!(!d.styles.default_para_present); - d.styles.default_para.space_after = Some(12.0); + Arc::make_mut(&mut d.styles).default_para.space_after = Some(12.0); let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); assert!(again.styles.default_para_present); assert_eq!(again.styles.default_para.space_after, Some(12.0)); @@ -2169,6 +2170,68 @@ fn theme_colour_wins_over_the_fixed_colour() { assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}"); } +/// `w:shd w:val="nil"` paints nothing despite its fill: a CV template's red cell showed over the name. +#[test] +fn nil_shading_paints_no_fill() { + let body = r#" + + + +x"#; + let d = read_body(body); + let Block::Table(t) = &*d.body[0] else { panic!("table first") }; + assert_eq!(t.rows[0].cells[0].props.shading, None); + assert_eq!(t.rows[0].cells[1].props.shading, Some(Rgb(0xC0, 0, 0))); + let p = paras(&d); + assert_eq!(p[0].props.shading, None); + assert_eq!(p[0].props_of_char(0).shading, None); +} + +/// A border's theme colour wins over its stale `w:color`: a template's white rule showed as black. +#[test] +fn border_theme_colour_wins_over_the_fixed_colour() { + let body = r#" +x + + +"#; + let styles = r#""#; + let d = themed_docx(body, styles, None); + let p = paras(&d); + let pb = p[0].props.borders.unwrap(); + assert_eq!(pb.top.and_then(|b| b.color), Some(Rgb(0xFF, 0xFF, 0xFF))); + assert_eq!(pb.left.and_then(|b| b.color), Some(Rgb(0xFF, 0, 0)), "no theme colour keeps the fixed one"); + assert_eq!(p[0].props_of_char(0).border.and_then(|b| b.color), Some(Rgb(0xED, 0x7D, 0x31))); + let Block::Table(t) = &*d.body[1] else { panic!("table second") }; + assert_eq!(t.props.borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x72, 0xC4))); + let cell = t.rows[0].cells[0].props.borders.and_then(|b| b.left).and_then(|b| b.color); + assert!(near(cell.map(TextColor::Rgb), Rgb(0x53, 0x81, 0x35)), "{cell:?}"); + assert_eq!(d.last_section.page_borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x54, 0x6A))); + let st = d.styles.get("T").and_then(|s| s.table.as_ref()).and_then(|t| t.borders); + assert_eq!(st.and_then(|b| b.bottom).and_then(|b| b.color), Some(Rgb(0xA5, 0xA5, 0xA5))); +} + +/// Given both `themeTint` and `themeShade`, Word applies the tint alone, for text colours, +/// shading and borders (MS-OI29500 17.3.2.6 b, 17.4.32 a; MS-OE376 2.3.2.3 a). +#[test] +fn theme_tint_wins_over_theme_shade() { + let both = r#"w:themeColor="accent1" w:themeTint="99" w:themeShade="7F""#; + let body = format!( + r#"a"# + ); + let d = themed_docx(&body, "", None); + let p = paras(&d); + let ch = p[0].props_of_char(0); + // accent1 4472C4 lightened by tint 99 alone, as Word writes it into w:val. + let tinted = Rgb(0x8E, 0xAA, 0xDB); + assert!(near(ch.color, tinted), "text colour {:?}", ch.color); + let run_border = ch.border.and_then(|b| b.color).map(TextColor::Rgb); + assert!(near(run_border, tinted), "text border {run_border:?}"); + let para_border = p[0].props.borders.and_then(|b| b.top).and_then(|b| b.color).map(TextColor::Rgb); + assert!(near(para_border, tinted), "paragraph border {para_border:?}"); + assert!(near(ch.shading.map(TextColor::Rgb), tinted), "shading {:?}", ch.shading); +} + #[test] fn theme_colours_follow_the_colour_scheme_mapping() { // Styles are read before settings; the mapping still applies to them. @@ -3878,6 +3941,144 @@ fn hyperlink_with_empty_target_is_no_link() { assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com")); } +/// Picture brightness, contrast and transparency (#1711, #1766): VML `gain`/`blacklevel` (Word's +/// watermark "Washout"), DrawingML `a:lum` and `a:alphaModFix`; saved as DrawingML and read back. +#[test] +fn picture_brightness_contrast_transparency() { + use wordcraft_doc::picfx::PicFx; + let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png"; + let vml = + |attrs: &str| format!(r#""#); + let dml = |blip: &str| { + format!( + r#"{blip}"# + ) + }; + let cases: Vec<(String, Option)> = vec![ + // Word's Washout: 0.3 and 0.35 in 16.16 fixed point. + (vml(r#"gain="19661f" blacklevel="22938f""#), Some(PicFx::new(70_000, -70_000, 0))), + (vml(r#"gain="0.5" blacklevel="0.25""#), Some(PicFx::new(50_000, -50_000, 0))), + (vml(r#"gain="131072f""#), Some(PicFx::new(0, 50_000, 0))), + (vml(""), Some(PicFx::default())), + (dml(r#""#), Some(PicFx::new(80_000, -80_000, 0))), + (dml(r#""#), Some(PicFx::new(-20_000, 0, 60_000))), + (dml(""), Some(PicFx::default())), + // Hostile values: clamped, never a panic. + (vml(r#"gain="NaNf" blacklevel="infinity""#), None), + (vml(r#"gain="-99999999f" blacklevel="99999999999f""#), None), + (vml(r#"gain="1e308" blacklevel="-1e308""#), None), + (vml(r#"gain="f" blacklevel="--3""#), None), + (dml(r#""#), None), + (dml(r#""#), None), + ]; + let body: String = cases.iter().map(|(x, _)| format!("{x}")).collect(); + let doc = format!(r#"{body}"#); + let r = rels(&[("rIdImg", "image", "media/pic.png")]); + let bytes = zip(&[ + ("_rels/.rels", ROOT_RELS.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/_rels/document.xml.rels", r.as_bytes()), + ("word/media/pic.png", png), + ]); + let d = wordcraft_docx::read(&bytes).unwrap(); + let fx_of = |d: &Document| -> Vec { + paras(d) + .iter() + .map(|p| match p.objects.first() { + Some(InlineObject::Image { fx, .. }) => *fx, + o => panic!("{o:?}"), + }) + .collect() + }; + let read = fx_of(&d); + assert_eq!(read.len(), cases.len()); + for ((x, want), got) in cases.iter().zip(&read) { + assert_eq!(*got, got.clamped(), "{x}"); + if let Some(want) = want { + let near = |a: i32, b: i32| (a - b).abs() <= 2; + assert!(near(got.bright, want.bright) && near(got.contrast, want.contrast) && got.transparency == want.transparency, "{x}: {got:?}"); + } + } + // Clamped hostile values. + assert_eq!(read[11], PicFx::new(100_000, -100_000, 100_000)); + assert_eq!(read[12], PicFx::default()); + // Saved as DrawingML (VML pictures too) and read back unchanged. + let out = wordcraft_docx::write(&d).unwrap(); + let back = wordcraft_docx::read(&out).unwrap(); + assert_eq!(fx_of(&back), read); +} + +/// Word's picture watermark with Washout (#1711): a VML picture in a header's Watermarks +/// building block keeps its brightness and contrast. +#[test] +fn washout_picture_watermark_in_header() { + let hdr = format!( + r##""## + ); + let hrels = rels(&[("rImg", "image", "media/wm.png")]); + let body = r#"Body"#; + let d = wordcraft_docx::read(&docx( + body, + &[("rHdr", "header", "header1.xml")], + &[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")], + )) + .unwrap(); + let id = d.last_section.headers.default.unwrap(); + let fx = d.parts[&id] + .blocks + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }) + .expect("watermark picture"); + assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}"); +} + +/// A faded DrawingML picture behind a header's text (#1766; #1961 review): `a:alphaModFix` reads +/// and is written back once, as in the body. +#[test] +fn faded_picture_behind_text_in_header() { + let hdr = format!( + r#"centercenter"# + ); + let hrels = rels(&[("rImg", "image", "media/wm.png")]); + let body = r#"Body"#; + let header_picture = |d: &wordcraft_doc::Document| { + let id = d.last_section.headers.default.unwrap(); + d.parts[&id] + .blocks + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .find_map(|o| if let InlineObject::Image { fx, float, .. } = o { Some((*fx, float.wrap)) } else { None }) + .expect("header picture") + }; + let d = wordcraft_docx::read(&docx( + body, + &[("rHdr", "header", "header1.xml")], + &[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")], + )) + .unwrap(); + let (fx, wrap) = header_picture(&d); + assert_eq!(fx, wordcraft_doc::picfx::PicFx::new(0, 0, 80_000)); + assert_eq!(wrap, wordcraft_doc::para::Wrap::BehindText); + let saved = wordcraft_docx::write(&d).unwrap(); + let again = wordcraft_docx::read(&saved).unwrap(); + assert_eq!(header_picture(&again), (fx, wrap), "the same picture after saving"); +} + +/// An OLE object's preview keeps the corrections of its picture (#1961 review). +#[test] +fn ole_preview_keeps_its_corrections() { + let body = r##""##; + let d = wordcraft_docx::read(&docx(body, &[("rIdImg", "image", "media/p.png")], &[("word/media/p.png", "not really a png")])).unwrap(); + let fx = d.body[0] + .as_para() + .and_then(|p| p.objects.iter().find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })) + .expect("the preview"); + assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}"); +} + #[test] fn row_table_borders_round_trip() { let d = read_body( @@ -4313,6 +4514,20 @@ fn vml_textbox_style_anchor_and_fit() { assert!(tb.body.auto_fit); } +/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only +/// the runs in that font get the text's font back, the other letter keeps its own. +#[test] +fn drop_cap_frame_keeps_other_fonts() { + let body = r#"Ab + c tale"#; + let d = read_body(body); + let p = paras(&d); + assert_eq!(p.len(), 1, "the frame merges into its paragraph"); + assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono")); + assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font"); + assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own"); +} + /// A VML horizontal rule (`o:hr`, a web page's `
` pasted into Word) is read as one: the whole /// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or /// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule. diff --git a/crates/docx/tests/it/glossary.rs b/crates/docx/tests/it/glossary.rs index d784375947..7b234542a0 100644 --- a/crates/docx/tests/it/glossary.rs +++ b/crates/docx/tests/it/glossary.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::io::{Read, Write}; +use std::sync::Arc; use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block}; @@ -191,7 +192,7 @@ fn glossary_entries_are_read_into_the_model() { fn edited_glossary_is_written_from_the_model() { let mut doc = wordcraft_docx::read(&with_glossary()).unwrap(); doc.glossary[0].name = "Sign-off".into(); - let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet); + let num = Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Bullet); let mut listed = Paragraph::with_text("Second line", CharProps::default()); listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); doc.glossary.push(BuildingBlock { diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index dda0e4ffec..b6c60dcce3 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -1,6 +1,7 @@ mod alt_chunks; mod bidi; mod bidi_runs; +mod border_lines; mod cell_margin_inheritance; mod char_units; mod citations; @@ -13,6 +14,7 @@ mod default_size; mod drawing_payload; mod east_asian_lang; mod east_asian_layout; +mod emphasis_marks; mod fixtures; mod glossary; mod heading_levels; @@ -31,6 +33,7 @@ mod section_page_number_type; mod section_text_direction; mod section_valign; mod settings_merge; +mod text_alignment; mod text_boxes; mod toc_ranges; mod undefined_style; diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index cedb8d12ac..6a04043ed6 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::io::{Read, Write}; +use std::sync::Arc; const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; @@ -272,7 +273,7 @@ fn comment_references_use_the_documents_annotation_reference_style() { assert!(!body.contains(r#"w:val="CommentReference""#), "{body}"); // A document without that style keeps the English id. let mut doc = wordcraft_doc::Document::new(); - doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference")); + Arc::make_mut(&mut doc.styles).styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference")); let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap(); doc.body = rebuilt.body.clone(); doc.parts = rebuilt.parts.clone(); @@ -314,7 +315,7 @@ fn list_identities_survive_and_a_copied_definition_gets_none() { // A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid. let mut copy = doc.numbering.abstracts[0].clone(); copy.id = 7; - doc.numbering.abstracts.push(copy); + Arc::make_mut(&mut doc.numbering).abstracts.push(copy); let n = text(&save(&doc), "word/numbering.xml"); assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}"); } diff --git a/crates/docx/tests/it/roundtrip.rs b/crates/docx/tests/it/roundtrip.rs index 03e04de951..98da6a4520 100644 --- a/crates/docx/tests/it/roundtrip.rs +++ b/crates/docx/tests/it/roundtrip.rs @@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; 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, + TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart}; use wordcraft_doc::styles::{Style, StyleKind}; @@ -95,6 +95,7 @@ fn every_char_prop_round_trips() { scale: Some(150.0), position: Some(3.0), kern: Some(8.0), + emphasis: Some(wordcraft_doc::props::EmphasisMark::Comma), ligatures: Some(wordcraft_doc::props::Ligatures { standard: true, historical: true, ..Default::default() }), outline: Some(true), shadow: Some(true), @@ -220,6 +221,7 @@ fn every_para_prop_round_trips() { num_change: None, fmt_change: None, snap_to_grid: Some(false), + text_alignment: Some(TextAlignment::Top), }; let variants = [ ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() }, @@ -248,7 +250,7 @@ fn every_para_prop_round_trips() { #[test] fn styles_and_lists_round_trip() { let mut d = Document::new(); - d.styles.upsert(Style { + Arc::make_mut(&mut d.styles).upsert(Style { id: "MyStyle".into(), name: "My Style".into(), kind: StyleKind::Paragraph, @@ -265,7 +267,7 @@ fn styles_and_lists_round_trip() { default: false, table: None, }); - d.styles.upsert(Style { + Arc::make_mut(&mut d.styles).upsert(Style { id: "MyStyleChar".into(), name: "My Style Char".into(), kind: StyleKind::Character, @@ -274,10 +276,10 @@ fn styles_and_lists_round_trip() { hidden: true, ..Default::default() }); - let bullets = d.numbering.add_list(ListKind::Bullet); - let nums = d.numbering.add_list(ListKind::Legal); - let outline = d.numbering.add_list(ListKind::Outline); - if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == outline) { + let bullets = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet); + let nums = Arc::make_mut(&mut d.numbering).add_list(ListKind::Legal); + let outline = Arc::make_mut(&mut d.numbering).add_list(ListKind::Outline); + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == outline) { n.start_overrides.push((0, 4)); } let mut ps = Vec::new(); @@ -427,6 +429,7 @@ fn images_round_trip() { // Room for a shadow: kept through save and load. float: Float { effect: [12.0, 12.0, 27.0, 27.0], ..Default::default() }, crop: [0.1, 0.0, 0.25, 0.05], + fx: Default::default(), ole: None, }; let floating = InlineObject::Image { @@ -436,6 +439,7 @@ fn images_round_trip() { alt: String::new(), float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 36.0, y: 12.5, dist: 9.0, ..Default::default() }, crop: [0.0; 4], + fx: Default::default(), ole: None, }; let aligned = InlineObject::Image { @@ -455,6 +459,7 @@ fn images_round_trip() { ..Default::default() }, crop: [0.0; 4], + fx: Default::default(), ole: None, }; let mut floats = vec![floating.clone(), aligned]; @@ -466,6 +471,7 @@ fn images_round_trip() { alt: String::new(), float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 0.0, y: 0.0, dist: 0.0, ..Default::default() }, crop: [0.0; 4], + fx: Default::default(), ole: None, }); } @@ -567,7 +573,7 @@ fn table_style_formatting_round_trips() { band_size: Some(3), ..Default::default() }; - d.styles.upsert(Style { + Arc::make_mut(&mut d.styles).upsert(Style { id: "Whole".into(), name: "Whole".into(), kind: StyleKind::Table, @@ -620,7 +626,7 @@ fn every_table_style_part_round_trips() { }; st.set_table_part(part, f); } - d.styles.upsert(st.clone()); + Arc::make_mut(&mut d.styles).upsert(st.clone()); let r = rt(&d); let got = r.styles.get("Parts").unwrap(); for part in TablePart::ALL { @@ -1188,7 +1194,16 @@ fn groups_round_trip() { let mut d = Document::new(); let key = d.add_media(tiny_png(), "png"); let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]); - let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4], ole: None }; + let pic = InlineObject::Image { + media: key, + w: 60.0, + h: 40.0, + alt: "A picture".into(), + float: Float::default(), + crop: [0.0; 4], + fx: Default::default(), + ole: None, + }; let oval = InlineObject::Shape { kind: ShapeKind::Ellipse, w: 50.0, @@ -1275,7 +1290,16 @@ fn drawing_canvas_round_trips() { use wordcraft_doc::para::{CanvasStyle, GroupChild}; let mut d = Document::new(); let key = d.add_media(tiny_png(), "png"); - let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "In the canvas".into(), float: Float::default(), crop: [0.0; 4], ole: None }; + let pic = InlineObject::Image { + media: key, + w: 60.0, + h: 40.0, + alt: "In the canvas".into(), + float: Float::default(), + crop: [0.0; 4], + fx: Default::default(), + ole: None, + }; let star = InlineObject::Shape { kind: ShapeKind::Star, w: 72.0, @@ -1708,7 +1732,7 @@ fn kitchen_sink() -> Document { let mut d = Document::new(); let png = tiny_png(); let key = d.add_media(png, "png"); - let num = d.numbering.add_list(ListKind::Numbered); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]); let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]); let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]); @@ -1733,7 +1757,16 @@ fn kitchen_sink() -> Document { let mut img = Paragraph::new(); img.insert_object( 0, - InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4], ole: None }, + InlineObject::Image { + media: key, + w: 30.0, + h: 20.0, + alt: "pic".into(), + float: Float::default(), + crop: [0.0; 4], + fx: Default::default(), + ole: None, + }, &CharProps::default(), ) .unwrap(); @@ -1828,6 +1861,7 @@ fn hostile_model_values_still_write() { alt: String::new(), float: Float::default(), crop: [f32::NAN; 4], + fx: Default::default(), ole: None, }, &CharProps::default(), @@ -1940,10 +1974,10 @@ fn list_level_overrides_round_trip() { use wordcraft_doc::numbering::{Counters, Level}; use wordcraft_doc::section::NumFormat; let mut d = Document::new(); - let num = d.numbering.add_list(ListKind::Numbered); - let restart = d.numbering.restart(num).unwrap(); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + let restart = Arc::make_mut(&mut d.numbering).restart(num).unwrap(); let own = Level { format: NumFormat::DecimalZero, text: "%1.%2".into(), indent: 26.5, hanging: 26.5, ..Level::default() }; - if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == restart) { + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == restart) { n.level_overrides = vec![(1, own.clone())]; } let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); @@ -2098,6 +2132,7 @@ fn rotation_and_flips_round_trip() { alt: String::new(), float: Float { rot: 30.0, effect: [6.0; 4], ..Default::default() }, crop: [0.0; 4], + fx: Default::default(), ole: None, }; let shape = |kind, float| InlineObject::Shape { @@ -2598,6 +2633,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() { alt: "A kiln at dusk".into(), float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() }, crop: [0.0; 4], + fx: Default::default(), ole: None, }; let shape = |alt: AltText| InlineObject::Shape { diff --git a/crates/docx/tests/it/text_alignment.rs b/crates/docx/tests/it/text_alignment.rs new file mode 100644 index 0000000000..d72d25ff29 --- /dev/null +++ b/crates/docx/tests/it/text_alignment.rs @@ -0,0 +1,64 @@ +//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values +//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`. +//! Word sizes a line by it when a list label is larger than the text, so a save that drops it +//! moves every line after. Fixtures are hand-written from the spec. + +use std::io::{Read, Write}; + +use wordcraft_doc::props::TextAlignment; + +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + 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 text_alignment_is_written_back_as_read() { + let para = |ppr: &str, text: &str| format!(r#"{ppr}{text}"#); + let body = [ + para(r#""#, "A"), + para(r#""#, "B"), + para(r#""#, "C"), + para(r#""#, "D"), + para(r#""#, "E"), + para(r#""#, "F"), + para("", "G"), + ] + .concat(); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let got: Vec> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect(); + let want = vec![ + Some(TextAlignment::Top), + Some(TextAlignment::Center), + Some(TextAlignment::Baseline), + Some(TextAlignment::Bottom), + Some(TextAlignment::Auto), + None, + None, + ]; + assert_eq!(got, want); + let out = wordcraft_docx::write(&d).unwrap(); + let xml = document_xml(&out); + assert!(xml.contains(r#""#), "{xml}"); + for v in ["center", "baseline", "bottom", "auto"] { + assert_eq!(xml.matches(&format!(r#""#)).count(), 1, "{v}: {xml}"); + } + assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}"); + let back = wordcraft_docx::read(&out).unwrap(); + assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::>(), want); +} diff --git a/crates/docx/tests/it/text_boxes.rs b/crates/docx/tests/it/text_boxes.rs index 0040e942ca..98144fdd33 100644 --- a/crates/docx/tests/it/text_boxes.rs +++ b/crates/docx/tests/it/text_boxes.rs @@ -215,6 +215,7 @@ fn picture_in_box_keeps_its_frame() { alt: "Synthetic marker".into(), float: Default::default(), crop: [0.0; 4], + fx: Default::default(), ole: None, }; let mut p = Paragraph::new(); diff --git a/crates/docx/tests/it/word_xml.rs b/crates/docx/tests/it/word_xml.rs index ca2dacaccb..dd614ae59f 100644 --- a/crates/docx/tests/it/word_xml.rs +++ b/crates/docx/tests/it/word_xml.rs @@ -106,7 +106,7 @@ fn wordml_2003_sample() -> String { A1B1 B2 - {png} + {png} Garden header @@ -117,6 +117,16 @@ fn wordml_2003_sample() -> String { ) } +/// Word 2003 XML pictures keep Word's Washout (gain 0.3, black level 0.35) as brightness and +/// contrast (#1711). +#[test] +fn word_2003_xml_picture_keeps_washout() { + let doc = read_xml(wordml_2003_sample().as_bytes()).unwrap(); + let ps = paras(&doc); + let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. })); + assert!(matches!(pic, Some(InlineObject::Image { fx, .. }) if (fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2), "{pic:?}"); +} + #[test] fn imports_word_2003_xml() { let xml = wordml_2003_sample(); diff --git a/crates/docx/tests/it/wordml2003_write.rs b/crates/docx/tests/it/wordml2003_write.rs index 039fd65f1b..0db8455494 100644 --- a/crates/docx/tests/it/wordml2003_write.rs +++ b/crates/docx/tests/it/wordml2003_write.rs @@ -32,7 +32,7 @@ fn sample() -> Document { d.core.title = "Garden & more".into(); d.core.creator = "Ada".into(); let key = d.add_media(tiny_png(), "png"); - let num = d.numbering.add_list(ListKind::Numbered); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]); let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]); let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]); @@ -69,7 +69,16 @@ fn sample() -> Document { let mut img = Paragraph::new(); img.insert_object( 0, - InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None }, + InlineObject::Image { + media: key, + w: 30.0, + h: 20.0, + alt: "dot".into(), + float: Default::default(), + crop: [0.0; 4], + fx: Default::default(), + ole: None, + }, &CharProps::default(), ) .unwrap(); @@ -180,6 +189,48 @@ fn big_and_deep_documents_write() { } } +/// Picture brightness and contrast (#1711) survive a Word 2003 XML save as VML `gain` and +/// `blacklevel`; transparency has no place on `v:imagedata` and is dropped. +#[test] +fn picture_brightness_and_contrast_round_trip() { + use wordcraft_doc::picfx::PicFx; + let cases = [PicFx::new(70_000, -70_000, 0), PicFx::new(-30_000, 50_000, 40_000), PicFx::default()]; + let mut d = Document::new(); + let key = d.add_media(tiny_png(), "png"); + d.body = cases + .iter() + .map(|fx| { + let mut p = Paragraph::new(); + let pic = InlineObject::Image { + media: key.clone(), + w: 30.0, + h: 20.0, + alt: "dot".into(), + float: Default::default(), + crop: [0.0; 4], + fx: *fx, + ole: None, + }; + p.insert_object(0, pic, &CharProps::default()).unwrap(); + para_block(p) + }) + .collect(); + let xml = write_wordml2003(&d).expect("write"); + let text = String::from_utf8_lossy(&xml); + assert!(text.contains(r#"gain="19661f""#) && text.contains(r#"blacklevel="22938f""#), "Word's Washout values"); + let back = read_xml(&xml).expect("read"); + let got: Vec = paras(&back) + .iter() + .flat_map(|p| p.objects.iter()) + .filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }) + .collect(); + assert_eq!(got.len(), cases.len()); + for (g, want) in got.iter().zip(cases) { + let near = |a: i32, b: i32| (a - b).abs() <= 2; + assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}"); + } +} + #[test] fn shared_header_and_repeated_note_are_not_inlined_per_reference() { // #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500 diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ee1b7c22b9..619b3ab0af 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -44,6 +44,8 @@ getrandom = { workspace = true } # Language packs (CP4.3): downloaded, verified and unpacked by the suite's font store. craft-font-store = { workspace = true } craft-font-catalog = { workspace = true } +# The local time zone's offset for Insert ▸ Date & Time and date fields (#1707); the web asks the browser's Date. +chrono = { workspace = true, features = ["clock"] } [target.'cfg(unix)'.dependencies] # O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666). diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 81ca0fd0e9..1028d84be1 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -70,6 +70,7 @@ Home|Font|Character Shading|format.shading Home|Font|Font Dialog|format.fontDialog Home|Font|Double Strikethrough|format.doubleStrikethrough Home|Font|Small Caps|format.smallCaps +Home|Font|Emphasis Mark|format.emphasisMark Home|Font|All Caps|format.allCaps Home|Font|Hidden|format.hidden Home|Font|Emboss|format.emboss diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 7d8295e0e9..dcbdb7c831 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -548,6 +548,59 @@ pub fn now_iso() -> String { iso_from_unix_secs(now_unix()) } +/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving +/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found. +pub fn local_offset_secs(unix: u64) -> i64 { + system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600) +} + +#[cfg(not(target_arch = "wasm32"))] +fn system_offset_secs(unix: u64) -> i64 { + use chrono::Offset; + let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else { + return 0; + }; + // chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so + // fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's + // panic hook (logged, no abort); the document is untouched. + std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0) +} + +/// On the web the browser's `Date` gives the offset directly (chrono isn't used there). +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn system_offset_secs(unix: u64) -> i64 { + let ms = (unix as f64) * 1000.0; + if !ms.is_finite() { + return 0; + } + // `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date. + let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset(); + if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 } +} + +/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC. +#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))] +fn system_offset_secs(_unix: u64) -> i64 { + 0 +} + +/// The local civil date and time now: see [`local_civil`]. +pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) { + let now = now_unix(); + local_civil(now, local_offset_secs(now)) +} + +/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since +/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from +/// UTC (east positive). Saturating, so a hostile offset or clock never overflows. +pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) { + let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs); + let days = local.div_euclid(86_400); + let t = local.rem_euclid(86_400) as u32; + let (y, m, d) = civil_from_days(days); + (y, m, d, (t / 3600, t / 60 % 60, t % 60), days) +} + /// Days since 1970-01-01 → (year, month, day). /// /// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index b305a06e38..fa07b3e60a 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -2,6 +2,7 @@ use serde_json::Value; use wordcraft_doc::{Pos, StoryRef}; +use wordcraft_layout::ViewMode; use super::{parse_pos, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -254,10 +255,24 @@ fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult { sel_result(s) } +/// Page Up (`dir < 0`) / Page Down: the same place on the page before or after, or in the views +/// that are one long page, the line a printed page's text above or below. fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult { let ext = extend(v); let l = s.layout(); let from = s.sel.focus.clone(); + // Web Layout, Draft and Outline lay the document out as one long page (#5290). + if !s.view.read_mode && matches!(s.view.mode, ViewMode::Web | ViewMode::Draft | ViewMode::Outline) { + // A selection can end in text a collapsed outline heading hides: page from where it shows. + let at = if l.caret_on(&from, s.page_hint).is_none() { super::outline::shown_pos(s, &from, false) } else { None }; + let at = at.unwrap_or_else(|| from.clone()); + let Some(c) = l.caret_on(&at, s.page_hint) else { return sel_result(s) }; + let page_h = l.pages.get(c.page).map_or(0.0, |p| p.h); + let y = (c.top + 1.0 + dir as f32 * page_step(s, &at)).min(page_h).max(0.0); + let to = l.line_toward(&at, s.page_hint, c.x, y, dir).unwrap_or(from); + apply(s, to, ext); + return sel_result(s); + } let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) }; let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) }; let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from); @@ -266,6 +281,16 @@ fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult { sel_result(s) } +/// How far Page Up/Down move in the continuous views: the height of the text area of the +/// section `at` is in (the first section when `at` isn't in the body), at least an inch. +fn page_step(s: &Session, at: &Pos) -> f32 { + let block = if at.story == StoryRef::Body { at.path.0.first().map_or(0, |&i| i as usize) } else { 0 }; + let sections = s.doc.sections(); + let sect = sections.iter().find(|(end, _)| *end >= block).or(sections.last()).map(|(_, p)| *p); + let body = sect.map_or(0.0, |p| p.page_h - p.margin_top.abs() - p.margin_bottom.abs()); + if body.is_finite() { body.max(72.0) } else { 72.0 } +} + fn set(s: &mut Session, v: &Value) -> CmdResult { let ext = extend(v); let to = if let Some(pv) = v.get("pos") { diff --git a/crates/engine/src/cmd/chart.rs b/crates/engine/src/cmd/chart.rs index 7fd4aba1c4..79a16560e7 100644 --- a/crates/engine/src/cmd/chart.rs +++ b/crates/engine/src/cmd/chart.rs @@ -194,12 +194,15 @@ fn edit(s: &mut Session, f: impl Fn(&mut ChartSpec) -> Result<(), CmdError>) -> Ok(out) } -/// Draw the chart of `o` again at its current size (after a resize), if it has a model. +/// Draw the chart of `o` again at its current size (after a resize, or when it opens), if it +/// has a model. It keeps the chart as read (its `source`): the chart itself is unchanged, and +/// saving writes that back with the look WordCraft doesn't model. pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) { if let InlineObject::Graphic { w, h, graphic, .. } = o && let Some(spec) = graphic.chart.as_deref() { - *graphic = Arc::new(chart_graphic(spec.clone(), theme, *w, *h)); + let source = graphic.source.clone(); + *graphic = Arc::new(Graphic { source, ..chart_graphic(spec.clone(), theme, *w, *h) }); } } diff --git a/crates/engine/src/cmd/citations/generated.rs b/crates/engine/src/cmd/citations/generated.rs index 364a6223be..60b0b89882 100644 --- a/crates/engine/src/cmd/citations/generated.rs +++ b/crates/engine/src/cmd/citations/generated.rs @@ -6,6 +6,7 @@ use super::{ Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader, TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key, }; +use std::sync::Arc; /// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "