mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-05 17:59:58 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c57b0775c | ||
|
|
28925fac8c | ||
|
|
35e960c850 | ||
|
|
9d5924c8b7 | ||
|
|
2fb91eece2 | ||
|
|
c21d5af2a6 | ||
|
|
ed92cb5838 | ||
|
|
ec3e14f98b | ||
|
|
83bef65a1d | ||
|
|
e919982fa2 | ||
|
|
2f49c8ad2e | ||
|
|
334494a39f | ||
|
|
db8d944c5c | ||
|
|
21a4e2702f | ||
|
|
0c54f94602 | ||
|
|
d4bbe8ba9a | ||
|
|
1397669dd5 | ||
|
|
737bab404f | ||
|
|
9c5b54a1a1 | ||
|
|
5a8e6e4894 | ||
|
|
22989a77da | ||
|
|
7f63cab7d4 | ||
|
|
5f9bb3f111 | ||
|
|
7a8819c296 | ||
|
|
c0ab7e29f9 | ||
|
|
fede86cd1a | ||
|
|
c230e2b13e | ||
|
|
122c85bd09 | ||
|
|
475ca5420b | ||
|
|
12a840f653 | ||
|
|
94cbdeb4cb | ||
|
|
107996dd24 | ||
|
|
7ac4a838c6 | ||
|
|
96286f0e96 | ||
|
|
5a4c45e79a | ||
|
|
6f552abf8e | ||
|
|
5f87d7461b | ||
|
|
6a50c1e374 | ||
|
|
4123bdc09f | ||
|
|
b3da7a4d36 | ||
|
|
5e4c92bf20 | ||
|
|
61a1808f0f | ||
|
|
208facfb92 | ||
|
|
811a847e86 | ||
|
|
c9ecb1a14b | ||
|
|
c38cd8c407 | ||
|
|
991a21dee6 | ||
|
|
cbe6a9d5f7 | ||
|
|
c419985c54 | ||
|
|
1c7b5013fa | ||
|
|
3f555bc85d | ||
|
|
650a97b623 | ||
|
|
78d71fa19c | ||
|
|
4932017711 | ||
|
|
0802b4fe70 | ||
|
|
75fc50fb77 | ||
|
|
1f026078be | ||
|
|
bf7715785c | ||
|
|
573b929ad6 | ||
|
|
5c427807d8 | ||
|
|
006d9e4b5b | ||
|
|
8218dea5bd | ||
|
|
92ecb40105 | ||
|
|
63ceaf5b80 | ||
|
|
69945e0ccb | ||
|
|
2df2bfc30e | ||
|
|
7c6341cf50 | ||
|
|
6d22e36196 | ||
|
|
958867b7e8 | ||
|
|
e3b000b650 | ||
|
|
604ac6ddb2 | ||
|
|
3c7aa5df90 | ||
|
|
87cfbac0ee | ||
|
|
b2734c28f4 | ||
|
|
ed231fd027 | ||
|
|
06d699dcb7 | ||
|
|
560565cdd7 | ||
|
|
1350f62bf0 | ||
|
|
8b9f726ea7 | ||
|
|
688cce4731 | ||
|
|
b6afa0ba3c | ||
|
|
6ec85141d5 | ||
|
|
2d6dff96cc | ||
|
|
d2f504e29a | ||
|
|
e847b2e5e3 | ||
|
|
fd1c5efe6c | ||
|
|
655b4312d3 | ||
|
|
773ef21f9f | ||
|
|
53bccd5442 | ||
|
|
ba58e70cc8 | ||
|
|
6f62716f08 | ||
|
|
56c85834f1 | ||
|
|
c26b952e83 | ||
|
|
57b03d9af8 | ||
|
|
ea420c467c | ||
|
|
5f0c08b68d | ||
|
|
08afab50b6 | ||
|
|
cd6b497c8a | ||
|
|
1340705fa6 | ||
|
|
98da959ceb | ||
|
|
96e63ee96b | ||
|
|
d9e2b92e41 | ||
|
|
d97e2f3a29 | ||
|
|
03fba6cb10 | ||
|
|
ea25e5dd3a | ||
|
|
c6f54f6a32 | ||
|
|
610233de3d | ||
|
|
68512842aa | ||
|
|
3e9e36796c | ||
|
|
3122c16e38 | ||
|
|
5709fa3626 | ||
|
|
3b37679f7c | ||
|
|
fa37c9adbe | ||
|
|
26c662961d | ||
|
|
29f99006e0 | ||
|
|
26a8182e91 | ||
|
|
b52f3ea68d | ||
|
|
d64ef7c3f9 | ||
|
|
bff0d1d925 | ||
|
|
f4dfd34373 | ||
|
|
0ce44996ec | ||
|
|
8fb57e1f6b | ||
|
|
9819939d98 | ||
|
|
94b89d95d6 | ||
|
|
c45945a2ea | ||
|
|
efb5071526 | ||
|
|
ae151ff341 | ||
|
|
969df57ea2 | ||
|
|
682ce4318a | ||
|
|
570408b04b | ||
|
|
ddf88c39df | ||
|
|
69b457f155 | ||
|
|
a591879294 | ||
|
|
54988ddfda | ||
|
|
4edb16a946 | ||
|
|
7f6d9fbb97 | ||
|
|
d746e11f65 | ||
|
|
d6fb543e31 | ||
|
|
0592a70d7e | ||
|
|
2158d7388f | ||
|
|
67b3bbfdb0 | ||
|
|
ae5c65b9dd | ||
|
|
1f8f9e8e2c | ||
|
|
cf23ca73c9 | ||
|
|
b2b988cdc8 | ||
|
|
1ff68d64e3 | ||
|
|
eb8d195188 | ||
|
|
051fd875ef | ||
|
|
fdbc3322b3 | ||
|
|
c73e9e0e87 | ||
|
|
a6ba7321db | ||
|
|
1eff715f10 | ||
|
|
ca2cf4e0fd | ||
|
|
6f6559aacb | ||
|
|
faac2c42bd | ||
|
|
08dd0906ca | ||
|
|
ffb155a032 | ||
|
|
7160f05014 | ||
|
|
dd2a651093 | ||
|
|
bcbc8619e9 | ||
|
|
ab5ee42049 | ||
|
|
6601aeaf09 | ||
|
|
e00ff04d4a | ||
|
|
220994decf | ||
|
|
ab0020d6f4 | ||
|
|
5702fa08b2 | ||
|
|
1a8932b3ed | ||
|
|
8865455cfb | ||
|
|
fdbe410704 | ||
|
|
0842d4faec | ||
|
|
14513ae5f6 | ||
|
|
75ee5e6a43 | ||
|
|
48ff99e52c | ||
|
|
b47079fc79 | ||
|
|
e635c8bd96 | ||
|
|
cef4338947 | ||
|
|
a330e4c503 | ||
|
|
5063000781 | ||
|
|
7d9968fd98 | ||
|
|
866c1f9cac | ||
|
|
7b25253286 | ||
|
|
0bb9add551 | ||
|
|
0a5b4221fd | ||
|
|
ffa309c266 | ||
|
|
52a8a5db76 | ||
|
|
421bc8cdcd | ||
|
|
3ce9a84f81 | ||
|
|
8d5485c8bd | ||
|
|
bc74bc23f5 | ||
|
|
7cdc8d85a4 | ||
|
|
bbe3547dd1 | ||
|
|
389fa90c29 | ||
|
|
84b4623e14 | ||
|
|
e2bc9e017e | ||
|
|
90898f454b | ||
|
|
807fe91724 | ||
|
|
41d46625e0 | ||
|
|
4f2d0da04c | ||
|
|
be2666da15 | ||
|
|
959770fa13 | ||
|
|
adfcbcbf7c | ||
|
|
8f65b867c0 | ||
|
|
2f08e8bb01 | ||
|
|
3164c33c28 | ||
|
|
6a62c6142f | ||
|
|
48c60a51b1 | ||
|
|
7be387dabc | ||
|
|
5cdf2608bf | ||
|
|
242e850aaa | ||
|
|
ee8622ddb3 | ||
|
|
078945e10c | ||
|
|
72327bdd36 | ||
|
|
fbe68547f4 | ||
|
|
56188b97f4 | ||
|
|
bc5d9f9fa5 | ||
|
|
e2b439ed17 | ||
|
|
1dd4e64191 | ||
|
|
ab02c85701 | ||
|
|
868fffa6fe | ||
|
|
89c0d516ae | ||
|
|
7848757954 | ||
|
|
3e2c79407d | ||
|
|
bf9e9fb8ae | ||
|
|
7aab0b202c | ||
|
|
c4d7948e68 | ||
|
|
d393e99879 | ||
|
|
1376a5476b | ||
|
|
34525f1cb1 | ||
|
|
905570a90b | ||
|
|
03c50f8af2 | ||
|
|
a8250e6472 | ||
|
|
1146691b90 | ||
|
|
c1490c5ba9 | ||
|
|
82418896f0 | ||
|
|
efb7d8ef20 | ||
|
|
2ef046bad9 | ||
|
|
e5d46bb242 | ||
|
|
9ffd30d968 | ||
|
|
96c6c19b9e | ||
|
|
919b8a5838 | ||
|
|
211377f550 | ||
|
|
09e9522a35 | ||
|
|
b7dad64d94 | ||
|
|
ce516b04f6 | ||
|
|
78bd6b2b72 | ||
|
|
693ccff3b2 | ||
|
|
cb7de0227f | ||
|
|
2b826a288c | ||
|
|
2036ad2e99 | ||
|
|
7c9d301541 | ||
|
|
04ff4e3a54 | ||
|
|
2877514d9e | ||
|
|
27b7f34425 | ||
|
|
4c0bb99016 | ||
|
|
b545bc2757 | ||
|
|
2bc5874da0 | ||
|
|
b114209d2b | ||
|
|
0b6b88f414 | ||
|
|
1e9c11fb1c | ||
|
|
3d464f3ebe | ||
|
|
36400f69eb | ||
|
|
6bb3f085aa | ||
|
|
9d5b98eb18 | ||
|
|
452d38da0b | ||
|
|
3181e502c5 | ||
|
|
8c3a792b49 | ||
|
|
c967f934ca | ||
|
|
d6cb183e6d | ||
|
|
449d2c2392 | ||
|
|
4ea6fea512 | ||
|
|
ccc8780b5e | ||
|
|
3110dcf02b | ||
|
|
fd7f6621a1 | ||
|
|
8a191fbf11 | ||
|
|
499fc6289e | ||
|
|
935aaf7147 | ||
|
|
1bd71f72bc | ||
|
|
bf80a31d10 | ||
|
|
a64d9748f2 | ||
|
|
c395760959 | ||
|
|
6ab1023ed6 | ||
|
|
639b2342c2 | ||
|
|
f683e308c6 | ||
|
|
4a3586c686 | ||
|
|
ba9c4000ed | ||
|
|
e5f2885ecc | ||
|
|
5f7f89c187 | ||
|
|
72e9593de9 | ||
|
|
6dff74990e | ||
|
|
6925818110 | ||
|
|
b87c80cdc7 | ||
|
|
8506d9da54 | ||
|
|
45b852ad4a | ||
|
|
7eee06a1ab | ||
|
|
b36e0b9fe9 | ||
|
|
16f0b6ed25 | ||
|
|
275037932f | ||
|
|
135ce2229d | ||
|
|
3cc5185b1e | ||
|
|
8686fa45ec | ||
|
|
d2b7465b1e | ||
|
|
86b1681b54 | ||
|
|
fe5d3c92d2 | ||
|
|
694ef8da96 | ||
|
|
5570236b70 | ||
|
|
27160e8821 | ||
|
|
5374375301 | ||
|
|
c6bb7a2f57 | ||
|
|
bb5dc4bcaa | ||
|
|
aea3b4c7d7 | ||
|
|
74da7e0985 | ||
|
|
422b59f3bc | ||
|
|
88a5fff9d5 | ||
|
|
f217aafa53 | ||
|
|
767594b034 | ||
|
|
f9d13f06dd | ||
|
|
836bf6553b | ||
|
|
e15a86bd55 | ||
|
|
1554cfad65 | ||
|
|
c6ff6a602b | ||
|
|
2718ca1b6b | ||
|
|
9dd0b51f23 | ||
|
|
488db37516 | ||
|
|
f719ff2f74 | ||
|
|
4c83e8a817 | ||
|
|
507e7b8050 | ||
|
|
cbc19809b6 | ||
|
|
3745441899 | ||
|
|
ce57478d15 | ||
|
|
21849ab7c0 | ||
|
|
3d70bf1bb1 | ||
|
|
6a30527aa4 | ||
|
|
4a752321d6 | ||
|
|
384ff126c6 | ||
|
|
64248032b2 | ||
|
|
e533073ceb | ||
|
|
a82d35bbff | ||
|
|
e4bac37236 | ||
|
|
bc50c6f7f9 | ||
|
|
084d26a3c8 | ||
|
|
1dbcf073a4 | ||
|
|
4959ac5f63 | ||
|
|
3402e9b644 | ||
|
|
9436271273 | ||
|
|
3abe452b7c | ||
|
|
7c20316dd5 | ||
|
|
6e96b5470f | ||
|
|
c077c155e4 | ||
|
|
55dce8eebf | ||
|
|
e490c13cdb | ||
|
|
0cd06c786c | ||
|
|
62f9050848 | ||
|
|
f1264f3047 | ||
|
|
057350ef6e | ||
|
|
056b18b714 | ||
|
|
20205a5051 | ||
|
|
574cef34c9 | ||
|
|
9e4adaf320 | ||
|
|
f6f9d65b0e | ||
|
|
b2787bf36e |
+20
-1
@@ -7,11 +7,30 @@ updates:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
|
||||
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
|
||||
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
|
||||
# crate families that must move together come before the catch-all.
|
||||
groups:
|
||||
egui:
|
||||
patterns:
|
||||
- "egui"
|
||||
- "egui_*"
|
||||
- "eframe"
|
||||
- "winit"
|
||||
fonts:
|
||||
patterns:
|
||||
- "skrifa"
|
||||
- "harfrust"
|
||||
hayro:
|
||||
patterns:
|
||||
- "hayro-*"
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'craft-i18n.rev'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
@@ -14,6 +15,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'craft-i18n.rev'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
@@ -41,6 +43,26 @@ jobs:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The interface catalogs and craft-proof's Language data come from craft-i18n at the commit in
|
||||
# craft-i18n.rev, so these tests build with the same data as the gate (ci.yml).
|
||||
- name: Check out craft-i18n
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
|
||||
|
||||
# The catalogs include CJK interface languages, so the UI tests need the suite's fonts, as in
|
||||
# the gate (same commit as ci.yml and release.yml).
|
||||
- name: Check out craft-fonts
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
|
||||
with:
|
||||
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
- name: Build with craft-fonts
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Linux build and software-rendering dependencies
|
||||
if: runner.os == 'Linux'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -20,7 +20,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
@@ -34,11 +34,12 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
|
||||
+1
-9
@@ -10,15 +10,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|---|---|---|---|---|
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
|
||||
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
|
||||
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
|
||||
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
|
||||
| `assets/spelling/en-variants.txt` | WordCraft contributors | original list written for this repository from general knowledge of English spelling; no other dictionary or variant list copied | MIT OR Apache-2.0 | ~280 American/British spelling pairs (-or/-our, -er/-re, -ize/-ise, -yze/-yse, -ense/-ence, doubled l, ae/oe…) with a policy per English: en-US, en-CA and the British-spelling Englishes (#1281, #1045) |
|
||||
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
|
||||
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
|
||||
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
|
||||
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
|
||||
| `assets/hyphenation/de.pat` | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr (German TeX hyphenation patterns, reformed spelling, 2013–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-de-1996.tex`, SHA-256 `374ad1ce3263f8a2791ec070ef9b516c532dae64456f3fb656e617e8ae53a9d3`), bundled unmodified | MIT (the notice is in the file's header) | 36,709 Liang patterns, no exception words; German hyphenation (de-DE, de-AT, de-CH) |
|
||||
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
@@ -70,3 +61,4 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
|
||||
| craft-i18n Language data (optional build input, not files in this repo): the spelling word lists and hyphenation patterns (`language-data/`: the Moby Hyphenator II list and our patterns for English, `en-extra.txt`, `en-variants.txt`, `ru-extra.txt`, and the hyph-utf8 German, Polish, Portuguese and Italian patterns) | see the Language data section of https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md (authors, upstream sources with SHA-256, licences); moved there from `assets/hyphenation/` and `assets/spelling/` byte for byte | embedded by craft-proof (storytold/craft-libs) only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | Public Domain / CC0-1.0, MIT, BSD-3-Clause, MIT OR Apache-2.0 (per file; see NOTICE) | Spelling and hyphenation; without it English spelling falls back to an installed Hunspell English or craft-proof's built-in Moby list, and pattern hyphenation is off |
|
||||
|
||||
@@ -20,7 +20,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
@@ -34,11 +34,12 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
|
||||
Generated
+196
-39
@@ -1011,6 +1011,45 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-atomic"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-catalog"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
|
||||
dependencies = [
|
||||
"fontique",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-font-store"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"craft-font-catalog",
|
||||
"flate2",
|
||||
"log",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"skrifa 0.47.0",
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"toml",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
@@ -1042,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",
|
||||
@@ -1061,6 +1100,17 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"toml",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1107,6 +1157,15 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1470,6 +1529,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"
|
||||
@@ -1537,6 +1609,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"
|
||||
@@ -1801,6 +1893,28 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontique"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"linebender_resource_handle",
|
||||
"memmap2",
|
||||
"objc2 0.6.5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"parlance",
|
||||
"read-fonts 0.41.0",
|
||||
"roxmltree",
|
||||
"smallvec",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"yeslogic-fontconfig-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -2855,7 +2969,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",
|
||||
@@ -3334,6 +3448,16 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3605,6 +3729,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parlance"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-writer"
|
||||
version = "0.15.0"
|
||||
@@ -4516,6 +4646,12 @@ dependencies = [
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -5723,6 +5859,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6106,6 +6243,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -6648,12 +6794,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"craft-proof",
|
||||
"craft-update",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
@@ -6676,14 +6823,14 @@ dependencies = [
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-ui-egui",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6696,11 +6843,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",
|
||||
@@ -6714,7 +6861,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6731,7 +6878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6743,6 +6890,7 @@ dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6755,10 +6903,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-font-catalog",
|
||||
"craft-font-store",
|
||||
"craft-proof",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
@@ -6778,7 +6929,6 @@ dependencies = [
|
||||
"wordcraft-geom",
|
||||
"wordcraft-layout",
|
||||
"wordcraft-pdf",
|
||||
"wordcraft-proof",
|
||||
"wordcraft-render",
|
||||
"zeroize",
|
||||
"zip 3.0.0",
|
||||
@@ -6786,7 +6936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6800,26 +6950,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",
|
||||
@@ -6827,8 +6985,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
"js-sys",
|
||||
"kurbo",
|
||||
@@ -6842,12 +7001,11 @@ dependencies = [
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"wordcraft-proof",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6859,7 +7017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6867,7 +7025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -6886,19 +7044,9 @@ dependencies = [
|
||||
"wordcraft-metafile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"proptest",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6912,8 +7060,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
@@ -6921,6 +7070,7 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -6943,8 +7093,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
@@ -6962,7 +7113,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7125,11 +7276,22 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.4"
|
||||
@@ -7473,11 +7635,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"
|
||||
|
||||
+15
-4
@@ -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"
|
||||
@@ -21,7 +21,16 @@ wordcraft-docbin = { path = "crates/docbin" }
|
||||
wordcraft-formats = { path = "crates/formats" }
|
||||
wordcraft-render = { path = "crates/render" }
|
||||
wordcraft-pdf = { path = "crates/pdf" }
|
||||
wordcraft-proof = { path = "crates/proof" }
|
||||
# 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 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" }
|
||||
wordcraft-mcp = { path = "crates/mcp" }
|
||||
wordcraft-zotero = { path = "crates/zotero" }
|
||||
@@ -58,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"
|
||||
@@ -83,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 }
|
||||
@@ -123,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" }
|
||||
|
||||
+50
@@ -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
|
||||
@@ -20,13 +20,77 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the
|
||||
packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them.
|
||||
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
- Language data (craft-i18n): the word lists, dictionaries and hyphenation patterns below are embedded at build
|
||||
time from https://github.com/storytold/craft-i18n (language-data/, CRAFT_I18N_DIR at the commit
|
||||
in craft-i18n.rev; all official releases) by craft-proof (storytold/craft-libs). Builds without
|
||||
CRAFT_I18N_DIR do not include them.
|
||||
- English word list (craft-i18n language-data/en-us/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
|
||||
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
|
||||
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
|
||||
(craft-i18n language-data/en-us/hyphenation.pat) were trained from it and are released as CC0-1.0.
|
||||
- English spelling dictionaries (craft-i18n language-data/en-us, en-gb and en-ca: spelling.aff and
|
||||
spelling.dic) — the SCOWL rel-2026.02.25 Hunspell dictionaries en_US, en_GB-ise and en_CA from
|
||||
https://wordlist.aspell.net, bundled unmodified. Their upstream notice (embedded too, for the
|
||||
About window) is language-data/en-us/LICENCE-SCOWL.txt in craft-i18n. The word lists:
|
||||
|
||||
Copyright 2000-2026 by Kevin Atkinson
|
||||
|
||||
Permission to use, copy, modify, distribute, and sell any part of SCOWLv2, or
|
||||
word lists created from it, is hereby granted without fee, provided that the
|
||||
above copyright notice appears in all copies and that both the above
|
||||
copyright notice and this notice appear in supporting documentation. Kevin
|
||||
Atkinson makes no representations about the suitability of this database for
|
||||
any purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
The Australian data:
|
||||
|
||||
Copyright 2016 by Benjamin Titze
|
||||
|
||||
Permission to use, copy, modify, distribute and sell this array, the
|
||||
associated software, and its documentation for any purpose is hereby
|
||||
granted without fee, provided that the above copyright notice appears
|
||||
in all copies and that both that copyright notice and this permission
|
||||
notice appear in supporting documentation. Benjamin Titze makes no
|
||||
representations about the suitability of this array for any
|
||||
purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
The affix files, a modified version of Geoff Kuenning's Ispell english.aff:
|
||||
|
||||
Copyright 1993, Geoff Kuenning, Granada Hills, CA
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. All modifications to the source code must be clearly marked as
|
||||
such. Binary redistributions based on modified source code
|
||||
must be clearly marked as modified versions in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
(clause 4 removed with permission from Geoff Kuenning)
|
||||
5. The name of Geoff Kuenning may not be used to endorse or promote
|
||||
products derived from this software without specific prior
|
||||
written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
- Polish hyphenation patterns (craft-i18n language-data/pl/hyphenation.pat) — hyph-pl.tex from the hyph-utf8
|
||||
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
|
||||
unmodified and used under the MIT License; the copyright and permission notice is in the file.
|
||||
- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8
|
||||
- Portuguese hyphenation patterns (craft-i18n language-data/pt/hyphenation.pat) — hyph-pt.tex from the hyph-utf8
|
||||
package, bundled unmodified and used under the BSD 3-Clause License:
|
||||
|
||||
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
|
||||
@@ -52,7 +116,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- German hyphenation patterns (assets/hyphenation/de.pat) — hyph-de-1996.tex (reformed spelling)
|
||||
- German hyphenation patterns (craft-i18n language-data/de/hyphenation.pat) — hyph-de-1996.tex (reformed spelling)
|
||||
from the hyph-utf8 package, bundled unmodified and used under the MIT License; the copyright
|
||||
and permission notice is in the file:
|
||||
|
||||
@@ -74,7 +138,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
|
||||
- Italian hyphenation patterns (craft-i18n language-data/it/hyphenation.pat) — hyph-it.tex from the hyph-utf8
|
||||
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
|
||||
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -140,6 +140,19 @@ cargo run --release -p wordcraft -- --sample # the desktop app with the s
|
||||
cargo run --release -p wordcraft -- report.docx # open a document
|
||||
```
|
||||
|
||||
The interface translations and the spelling and hyphenation data come from
|
||||
[craft-i18n](https://github.com/storytold/craft-i18n), an optional build input. To build with them,
|
||||
check it out at the commit in `craft-i18n.rev` and point `CRAFT_I18N_DIR` at it (an absolute
|
||||
path); without it WordCraft builds with an English interface, English spelling falls back to an
|
||||
installed Hunspell English or craft-proof's built-in word list, and only Bulgarian, Turkish and
|
||||
Russian hyphenate (by rule):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-i18n ../craft-i18n
|
||||
git -C ../craft-i18n checkout "$(cat craft-i18n.rev)"
|
||||
CRAFT_I18N_DIR="$PWD/../craft-i18n" cargo run --release -p wordcraft -- --sample
|
||||
```
|
||||
|
||||
Command line:
|
||||
|
||||
```sh
|
||||
@@ -194,7 +207,7 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `wordcraft-geom` | units and measurements |
|
||||
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
|
||||
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` ([`craft-proof`](https://github.com/storytold/craft-libs/tree/main/crates/proof) from craft-libs) | document model and editing; fonts and shaping; spelling, grammar, hyphenation (data: craft-i18n `language-data/`, `CRAFT_I18N_DIR`) |
|
||||
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
|
||||
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
|
||||
| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog |
|
||||
@@ -286,10 +299,21 @@ adapter. The log file records the choice (lines starting with
|
||||
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
|
||||
(Ubuntu 24.04, Debian 13 and newer).
|
||||
|
||||
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
|
||||
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
|
||||
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
|
||||
`speech-dispatcher`, so the package manager installs one of the two by default. With the
|
||||
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
|
||||
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
|
||||
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
|
||||
|
||||
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
|
||||
winit (the windowing library) supports dragging files into the window only on X11. To use the
|
||||
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
|
||||
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
|
||||
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
|
||||
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
|
||||
XWayland:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
|
||||
+172
-4
File diff suppressed because one or more lines are too long
@@ -430,7 +430,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = arg_value(&rest, "--format");
|
||||
let format = match find_option_arg(&rest, "--format") {
|
||||
OptionArg::Absent => None,
|
||||
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
|
||||
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
|
||||
OptionArg::Value(value) => Some(value),
|
||||
};
|
||||
let paths = positionals(&rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
@@ -758,6 +763,12 @@ fn parity_markdown(p: &Value) -> String {
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
// Language packs installed in the suite's font store are used; `proof.installPack` installs
|
||||
// only while the person has turned downloading on in the app.
|
||||
wordcraft_engine::packs::use_font_store(false);
|
||||
if let Some(source) = wordcraft_engine::packs::index_override() {
|
||||
eprintln!("wordcraft-cli: Language packs come from a test source: {source}");
|
||||
}
|
||||
match run(&args) {
|
||||
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
|
||||
Err(Failure::Error(e)) => {
|
||||
|
||||
@@ -75,6 +75,22 @@ fn unknown_options_are_rejected_before_file_access() {
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_malformed_format_options_before_file_access() {
|
||||
for (options, error) in [
|
||||
(vec!["--format"], "convert: --format requires a value"),
|
||||
(vec!["--format", "--markup"], "convert: --format requires a value"),
|
||||
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
|
||||
] {
|
||||
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
|
||||
argv.extend(args(&options));
|
||||
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
|
||||
}
|
||||
// A valid single value reaches file access, rather than a format-option error.
|
||||
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
|
||||
assert!(error.starts_with("missing.docx:"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
|
||||
for cmd in SUBCOMMANDS {
|
||||
|
||||
@@ -16,6 +16,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -29,4 +31,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
|
||||
+65
-107
@@ -1,14 +1,20 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
|
||||
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
|
||||
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages and
|
||||
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
|
||||
//!
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
|
||||
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
|
||||
//! bytes, so it says `file: false` and a host sends `bytes`.
|
||||
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
|
||||
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
|
||||
//! copied.
|
||||
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
|
||||
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
|
||||
//! the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
@@ -17,70 +23,57 @@
|
||||
//! is now that file; the next Save sends its name with `false`.
|
||||
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
|
||||
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
|
||||
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
|
||||
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
|
||||
//! user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
/// The app id in the host messages (`wordcraft:ready`).
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "Document.docx";
|
||||
|
||||
/// The largest document taken from the host page, refused before it is copied.
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
host: Rc<Host>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// Hand saved and exported files to the host instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the parent too.
|
||||
// Anything else that would download goes to the host too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
// The crate does not repeat a state it already reported.
|
||||
let host = self.host.clone();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
@@ -92,78 +85,43 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
/// Hear `wordcraft:open` and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
|
||||
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
|
||||
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
|
||||
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
|
||||
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
|
||||
return;
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
|
||||
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
|
||||
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
|
||||
//! either host mode.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -9,7 +9,8 @@ use wordcraft_engine::Session;
|
||||
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", "tex", "json", "xml"];
|
||||
const DOC_EXTS: &[&str] =
|
||||
&["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;
|
||||
@@ -27,9 +28,12 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
|
||||
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
|
||||
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
|
||||
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
|
||||
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
|
||||
let bridge = crate::host::Bridge::detect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
@@ -654,6 +658,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",
|
||||
|
||||
@@ -38,13 +38,14 @@ fmv-macos-events = "=0.1.0"
|
||||
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
|
||||
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
|
||||
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
|
||||
# traffic lights, the window, button and view ones.
|
||||
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
|
||||
objc2 = "0.6"
|
||||
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
|
||||
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
|
||||
# the build through arboard, at this version; this adds only the NSString bindings.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
|
||||
# for the clipboard's RTF (`public.rtf`).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
@@ -58,6 +59,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
|
||||
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
|
||||
# `wayland-data-control` feature, at this version.
|
||||
wl-clipboard-rs = "0.9"
|
||||
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
|
||||
# through arboard, at this version.
|
||||
x11rb = "0.13"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
|
||||
+222
-15
@@ -18,6 +18,11 @@
|
||||
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
|
||||
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
|
||||
//! add MIME types of other names, so there the HTML carries the MathML alone.
|
||||
//!
|
||||
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
|
||||
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
|
||||
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
|
||||
//! and HTML only; reading another program's RTF works on all four.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
@@ -31,17 +36,24 @@ thread_local! {
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
|
||||
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
|
||||
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
|
||||
/// session without the data-control protocol, where arboard would fall back to X11 and native
|
||||
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
|
||||
/// carries it.
|
||||
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
|
||||
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
|
||||
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
|
||||
/// other copy) in the platform's MathML formats. `false` when the text and HTML couldn't be set
|
||||
/// (the caller copies the text alone), including a Wayland session without the data-control
|
||||
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
|
||||
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
|
||||
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
|
||||
if platform::set_rich(text, html, rtf) {
|
||||
return true;
|
||||
}
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !rtf.is_empty() {
|
||||
platform::add_rtf(rtf);
|
||||
}
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
@@ -54,7 +66,8 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
@@ -76,8 +89,8 @@ pub fn html() -> Option<String> {
|
||||
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
|
||||
}
|
||||
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
|
||||
/// (`text/rtf`); not offered by arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
@@ -176,15 +189,51 @@ mod platform {
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
|
||||
/// aliases), HTML and RTF, served from a background thread until something else is copied.
|
||||
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
|
||||
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
if !wayland() {
|
||||
return false;
|
||||
}
|
||||
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
|
||||
// The text first: the plain-text aliases serve the first text type offered.
|
||||
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
|
||||
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
|
||||
if !data.is_empty() {
|
||||
sources.push(offer(mime, data));
|
||||
}
|
||||
}
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
/// RTF types a clipboard may offer, best first.
|
||||
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
|
||||
|
||||
/// The RTF type to ask for among those `offered`.
|
||||
fn rtf_target(offered: &[String]) -> Option<&'static str> {
|
||||
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
|
||||
}
|
||||
|
||||
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
let mime = rtf_target(&formats())?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
@@ -196,6 +245,125 @@ mod platform {
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
|
||||
}
|
||||
|
||||
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
|
||||
/// and at most `cap` bytes.
|
||||
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
|
||||
bytes_after == 0 && len <= cap
|
||||
}
|
||||
|
||||
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
|
||||
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
|
||||
mod x11 {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::Event;
|
||||
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
/// How long a paste waits for the selection's owner, in all.
|
||||
const TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Most targets looked at.
|
||||
const MAX_TARGETS: usize = 128;
|
||||
|
||||
struct Reader {
|
||||
conn: RustConnection,
|
||||
win: Window,
|
||||
clipboard: Atom,
|
||||
prop: Atom,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
|
||||
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn new() -> Option<Self> {
|
||||
let (conn, screen) = x11rb::connect(None).ok()?;
|
||||
let root = conn.setup().roots.get(screen)?.root;
|
||||
let win = conn.generate_id().ok()?;
|
||||
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
|
||||
let clipboard = atom(&conn, "CLIPBOARD")?;
|
||||
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
|
||||
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
|
||||
}
|
||||
|
||||
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
|
||||
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
|
||||
/// its first `cap` bytes.
|
||||
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
|
||||
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
|
||||
self.conn.flush().ok()?;
|
||||
loop {
|
||||
// Checked every time round, so a stream of other events can't keep us here.
|
||||
if Instant::now() >= self.deadline {
|
||||
return None;
|
||||
}
|
||||
match self.conn.poll_for_event().ok()? {
|
||||
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
|
||||
if e.property == x11rb::NONE {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => std::thread::sleep(Duration::from_millis(5)),
|
||||
}
|
||||
}
|
||||
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
|
||||
// as `bytes_after`.
|
||||
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
|
||||
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
|
||||
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.conn.destroy_window(self.win);
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
|
||||
/// arrive in pieces (INCR), which aren't read: `None` then.
|
||||
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
|
||||
let r = Reader::new()?;
|
||||
// The first targets only: a longer list is cut short, not refused.
|
||||
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
|
||||
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
|
||||
let names: Vec<String> =
|
||||
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
|
||||
let target = atom(&r.conn, super::rtf_target(&names)?)?;
|
||||
let incr = atom(&r.conn, "INCR")?;
|
||||
let reply = r.convert(target, cap, true)?;
|
||||
(reply.type_ != incr).then_some(reply.value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rtf_target_prefers_text_rtf() {
|
||||
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
|
||||
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
|
||||
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
|
||||
assert_eq!(rtf_target(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_reads_must_be_whole_and_capped() {
|
||||
assert!(complete(10, 0, 10));
|
||||
assert!(!complete(11, 0, 10), "over the cap");
|
||||
assert!(!complete(8, 4, 10), "more left to read");
|
||||
assert!(complete(0, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -251,6 +419,23 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
let Some(format) = raw::register_format("Rich Text Format") else { return };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return };
|
||||
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
|
||||
bytes.extend_from_slice(rtf.as_bytes());
|
||||
bytes.push(0);
|
||||
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
|
||||
log::warn!("clipboard RTF: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
|
||||
/// text (with its terminating NUL, as Windows text formats have).
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -276,14 +461,18 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::NSString;
|
||||
use objc2_foundation::{NSData, NSString};
|
||||
|
||||
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
|
||||
const RTF: &str = "public.rtf";
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
None
|
||||
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
|
||||
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
@@ -294,6 +483,18 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
/// arboard sets the text and HTML.
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
|
||||
log::warn!("clipboard: the RTF couldn't be added");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
|
||||
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
@@ -329,5 +530,11 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -15,9 +15,25 @@ use eframe::{
|
||||
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
|
||||
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
|
||||
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
|
||||
///
|
||||
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
|
||||
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
|
||||
// Short-circuits: only x86_64 macOS builds ask the system.
|
||||
macos && x86_64 && !runs_on_apple_silicon()
|
||||
}
|
||||
|
||||
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
|
||||
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
|
||||
/// the system can't tell.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn runs_on_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
@@ -181,7 +197,8 @@ mod tests {
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
|
||||
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
@@ -205,6 +222,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
|
||||
fn host_is_apple_silicon() -> bool {
|
||||
cfg!(target_os = "macos")
|
||||
&& std::process::Command::new("/usr/sbin/sysctl")
|
||||
.args(["-n", "hw.optional.arm64"])
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
|
||||
}
|
||||
|
||||
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
|
||||
/// wgpu's indirect-call validation.
|
||||
#[test]
|
||||
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
|
||||
if !host_is_apple_silicon() {
|
||||
return;
|
||||
}
|
||||
// What main.rs passes from an x86_64 build.
|
||||
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
|
||||
let mut options = NativeOptions::default();
|
||||
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
|
||||
+117
-17
@@ -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<bool>, Option<egui::Rect>) {
|
||||
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<egui::Rect> =
|
||||
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
|
||||
@@ -216,13 +253,45 @@ fn load_user_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
|
||||
/// BSDs, the display server (`load_prefs` runs after).
|
||||
fn saved_ui() -> Option<UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
UiState::from_json(&bytes)?.window?.sanitized()
|
||||
UiState::from_json(&bytes)
|
||||
}
|
||||
|
||||
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
|
||||
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
|
||||
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
|
||||
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
|
||||
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
|
||||
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
|
||||
/// available falls back to the other instead of failing to start.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
|
||||
use wordcraft_ui_egui::DisplayServer;
|
||||
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
|
||||
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
|
||||
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
|
||||
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
|
||||
}
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
"native Wayland"
|
||||
} else if set("DISPLAY") {
|
||||
"X11 (no Wayland)"
|
||||
} else {
|
||||
"neither (no Wayland or X server found)"
|
||||
};
|
||||
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -275,7 +344,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..Default::default()
|
||||
@@ -449,25 +518,28 @@ fn main() -> eframe::Result {
|
||||
// FXC, which needs no DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
|
||||
// build under Rosetta on Apple silicon keeps them (#5199).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let restored = saved_window();
|
||||
let saved = saved_ui();
|
||||
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
{
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
@@ -490,11 +562,13 @@ fn main() -> eframe::Result {
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
load_user_dictionary(&mut app);
|
||||
// Language packs from the suite's font store; an Install click is the person's own.
|
||||
wordcraft_engine::packs::use_font_store(true);
|
||||
// A region chosen in File › Options replaces the system's for the first blank
|
||||
// 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()
|
||||
@@ -579,6 +653,32 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
|
||||
/// server, and a choice that isn't available falls back to the other.
|
||||
#[test]
|
||||
fn the_display_server_follows_the_variable_then_the_setting() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let x11 = |setting, vars: &[(&str, &str)]| {
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
assert!(x11(X11, &both));
|
||||
assert!(!x11(Wayland, &both));
|
||||
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
|
||||
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
|
||||
for setting in [Auto, Wayland, X11] {
|
||||
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
|
||||
}
|
||||
}
|
||||
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
|
||||
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
|
||||
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
|
||||
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
|
||||
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,431 +0,0 @@
|
||||
% title: Hyphenation patterns for Italian
|
||||
% copyright: Copyright (C) 2008-2011 Claudio Beccari
|
||||
% notice: This file is part of the hyph-utf8 package.
|
||||
% See http://www.hyphenation.org/tex for more information.
|
||||
% language:
|
||||
% name: Italian
|
||||
% tag: it
|
||||
% version: 4.9 2014/04/22
|
||||
% authors:
|
||||
% -
|
||||
% name: Claudio Beccari
|
||||
% contact: claudio.beccari (at) gmail.com
|
||||
% licence:
|
||||
% - This file is available under any of the following licences:
|
||||
% -
|
||||
% name: LPPL
|
||||
% version: 1.3
|
||||
% or_later: true
|
||||
% url: http://www.latex-project.org/lppl.txt
|
||||
% status: maintained
|
||||
% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com
|
||||
% -
|
||||
% name: MIT
|
||||
% url: https://opensource.org/licenses/MIT
|
||||
% text: >
|
||||
% Permission is hereby granted, free of charge, to any person
|
||||
% obtaining a copy of this software and associated documentation
|
||||
% files (the "Software"), to deal in the Software without
|
||||
% restriction, including without limitation the rights to use,
|
||||
% copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
% copies of the Software, and to permit persons to whom the
|
||||
% Software is furnished to do so, subject to the following
|
||||
% conditions:
|
||||
%
|
||||
% The above copyright notice and this permission notice shall be
|
||||
% included in all copies or substantial portions of the Software.
|
||||
%
|
||||
% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
% OTHER DEALINGS IN THE SOFTWARE.
|
||||
% hyphenmins:
|
||||
% typesetting:
|
||||
% left: 2
|
||||
% right: 2
|
||||
% changes:
|
||||
% - 2014-04-22 - Add few patterns involving `h'
|
||||
% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3.
|
||||
% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana.
|
||||
% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package.
|
||||
% - 2008-03-08 - (last change in ithyph.tex)
|
||||
% texlive:
|
||||
% encoding: ascii
|
||||
% babelname: italian
|
||||
% legacy_patterns: ithyph.tex
|
||||
% message: Italian hyphenation patterns
|
||||
% description: |-
|
||||
% Hyphenation patterns for Italian in ASCII encoding.
|
||||
% Compliant with the Recommendation UNI 6461 on hyphenation
|
||||
% issued by the Italian Standards Institution
|
||||
% (Ente Nazionale di Unificazione UNI).
|
||||
% ==========================================
|
||||
%
|
||||
% These hyphenation patterns for the Italian language are supposed to comply
|
||||
% with the Recommendation UNI 6461 on hyphenation issued by the Italian
|
||||
% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee
|
||||
% or declaration of fitness to any particular purpose is given and any
|
||||
% liability is disclaimed.
|
||||
%
|
||||
\patterns{
|
||||
.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,...
|
||||
.anti1
|
||||
.anti3m2n
|
||||
.bio1
|
||||
.ca4p3s
|
||||
.circu2m1
|
||||
.contro1
|
||||
.di2s3cine
|
||||
.e2x1eu
|
||||
.fran2k3
|
||||
.free3
|
||||
.li3p2sa
|
||||
.narco1
|
||||
.opto1
|
||||
.orto3p2
|
||||
.para1
|
||||
.ph2l
|
||||
.ph2r
|
||||
.poli3p2
|
||||
.pre1
|
||||
.p2s
|
||||
.re1i2scr
|
||||
.sha2re3
|
||||
.tran2s3c
|
||||
.tran2s3d
|
||||
.tran2s3l
|
||||
.tran2s3n
|
||||
.tran2s3p
|
||||
.tran2s3r
|
||||
.tran2s3t
|
||||
.su2b3lu
|
||||
.su2b3r
|
||||
.wa2g3n
|
||||
.wel2t1
|
||||
2'2
|
||||
a1ia
|
||||
a1ie
|
||||
a1io
|
||||
a1iu
|
||||
a1uo
|
||||
a1ya
|
||||
2at.
|
||||
e1iu
|
||||
e2w
|
||||
o1ia
|
||||
o1ie
|
||||
o1io
|
||||
o1iu
|
||||
1b
|
||||
2bb
|
||||
2bc
|
||||
2bd
|
||||
2bf
|
||||
2bm
|
||||
2bn
|
||||
2bp
|
||||
2bs
|
||||
2bt
|
||||
2bv
|
||||
b2l
|
||||
b2r
|
||||
2b.
|
||||
2b'
|
||||
1c
|
||||
2cb
|
||||
2cc
|
||||
2cd
|
||||
2cf
|
||||
2ck
|
||||
2cm
|
||||
2cn
|
||||
2cq
|
||||
2cs
|
||||
2ct
|
||||
2cz
|
||||
2chh
|
||||
c2h
|
||||
2ch.
|
||||
2ch'.
|
||||
2ch''.
|
||||
2chb
|
||||
ch2r
|
||||
2chn
|
||||
c2l
|
||||
c2r
|
||||
2c.
|
||||
2c'
|
||||
.c2
|
||||
1d
|
||||
2db
|
||||
2dd
|
||||
2dg
|
||||
2dl
|
||||
2dm
|
||||
2dn
|
||||
2dp
|
||||
d2r
|
||||
2ds
|
||||
2dt
|
||||
2dv
|
||||
2dw
|
||||
2d.
|
||||
2d'
|
||||
.d2
|
||||
1f
|
||||
2fb
|
||||
2fg
|
||||
2ff
|
||||
2fn
|
||||
f2l
|
||||
f2r
|
||||
2fs
|
||||
2ft
|
||||
2f.
|
||||
2f'
|
||||
1g
|
||||
2gb
|
||||
2gd
|
||||
2gf
|
||||
2gg
|
||||
g2h
|
||||
g2l
|
||||
2gm
|
||||
g2n
|
||||
2gp
|
||||
g2r
|
||||
2gs
|
||||
2gt
|
||||
2gv
|
||||
2gw
|
||||
2gz
|
||||
2gh2t
|
||||
2g.
|
||||
2g'
|
||||
.h2
|
||||
1h
|
||||
2hb
|
||||
2hd
|
||||
2hh
|
||||
hi3p2n
|
||||
h2l
|
||||
2hm
|
||||
2hn
|
||||
2hr
|
||||
2hv
|
||||
2h.
|
||||
2h'
|
||||
.j2
|
||||
1j
|
||||
2j.
|
||||
2j'
|
||||
.k2
|
||||
1k
|
||||
2kg
|
||||
2kf
|
||||
k2h
|
||||
2kk
|
||||
k2l
|
||||
2km
|
||||
k2r
|
||||
2ks
|
||||
2kt
|
||||
2k.
|
||||
2k'
|
||||
1l
|
||||
2lb
|
||||
2lc
|
||||
2ld
|
||||
2l3f2
|
||||
2lg
|
||||
l2h
|
||||
l2j
|
||||
2lk
|
||||
2ll
|
||||
2lm
|
||||
2ln
|
||||
2lp
|
||||
2lq
|
||||
2lr
|
||||
2ls
|
||||
2lt
|
||||
2lv
|
||||
2lw
|
||||
2lz
|
||||
2l.
|
||||
2l'.
|
||||
2l''
|
||||
1m
|
||||
2mb
|
||||
2mc
|
||||
2mf
|
||||
2ml
|
||||
2mm
|
||||
2mn
|
||||
2mp
|
||||
2mq
|
||||
2mr
|
||||
2ms
|
||||
2mt
|
||||
2mv
|
||||
2mw
|
||||
2m.
|
||||
2m'
|
||||
1n
|
||||
2nb
|
||||
2nc
|
||||
2nd
|
||||
2nf
|
||||
2ng
|
||||
2nk
|
||||
2nl
|
||||
2nm
|
||||
2nn
|
||||
2np
|
||||
2nq
|
||||
2nr
|
||||
2ns
|
||||
n2s3fer
|
||||
2nt
|
||||
2nv
|
||||
2nz
|
||||
n2g3n
|
||||
2nheit
|
||||
2n.
|
||||
2n'
|
||||
1p
|
||||
2pd
|
||||
p2h
|
||||
p2l
|
||||
2pn
|
||||
3p2ne
|
||||
2pp
|
||||
p2r
|
||||
2ps
|
||||
3p2sic
|
||||
2pt
|
||||
2pz
|
||||
2p.
|
||||
2p'
|
||||
1q
|
||||
2qq
|
||||
2q.
|
||||
2q'
|
||||
1r
|
||||
2rb
|
||||
2rc
|
||||
2rd
|
||||
2rf
|
||||
r2h
|
||||
2rg
|
||||
2rk
|
||||
2rl
|
||||
2rm
|
||||
2rn
|
||||
2rp
|
||||
2rq
|
||||
2rr
|
||||
2rs
|
||||
2rt
|
||||
r2t2s3
|
||||
2rv
|
||||
2rx
|
||||
2rw
|
||||
2rz
|
||||
2r.
|
||||
2r'
|
||||
1s2
|
||||
2shm
|
||||
2sh.
|
||||
2sh'
|
||||
2s3s
|
||||
s4s3m
|
||||
2s3p2n
|
||||
2stb
|
||||
2stc
|
||||
2std
|
||||
2stf
|
||||
2stg
|
||||
2stm
|
||||
2stn
|
||||
2stp
|
||||
2sts
|
||||
2stt
|
||||
2stv
|
||||
2sz
|
||||
4s.
|
||||
4s'.
|
||||
4s''
|
||||
.t2
|
||||
1t
|
||||
2tb
|
||||
2tc
|
||||
2td
|
||||
2tf
|
||||
2tg
|
||||
t2h
|
||||
2th.
|
||||
t2l
|
||||
2tm
|
||||
2tn
|
||||
2tp
|
||||
t2r
|
||||
t2s
|
||||
3t2sch
|
||||
2tt
|
||||
t2t3s
|
||||
2tv
|
||||
2tw
|
||||
t2z
|
||||
2tzk
|
||||
tz2s
|
||||
2t.
|
||||
2t'.
|
||||
2t''
|
||||
1v
|
||||
2vc
|
||||
v2l
|
||||
v2r
|
||||
2vv
|
||||
2v.
|
||||
2v'.
|
||||
2v''
|
||||
1w
|
||||
w2h
|
||||
wa2r
|
||||
2w1y
|
||||
2w.
|
||||
2w'
|
||||
1x
|
||||
2xb
|
||||
2xc
|
||||
2xf
|
||||
2xh
|
||||
2xm
|
||||
2xp
|
||||
2xt
|
||||
2xw
|
||||
2x.
|
||||
2x'
|
||||
y1ou
|
||||
y1i
|
||||
1z
|
||||
2zb
|
||||
2zd
|
||||
2zl
|
||||
2zn
|
||||
2zp
|
||||
2zt
|
||||
2zs
|
||||
2zv
|
||||
2zz
|
||||
2z.
|
||||
2z'.
|
||||
2z''
|
||||
.z2
|
||||
} % Pattern end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,503 +0,0 @@
|
||||
% title: Hyphenation patterns for Portuguese
|
||||
% copyright: Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, 2024 Leonardo Araujo and Aline Benevides
|
||||
% notice: This file is part of the hyph-utf8 package.
|
||||
% See http://www.hyphenation.org/tex for more information.
|
||||
% language:
|
||||
% name: Portuguese
|
||||
% tag: pt
|
||||
% version: 1.4 2024-07-13
|
||||
% authors:
|
||||
% -
|
||||
% name: Pedro J. de Rezende
|
||||
% contact: rezende (at) ic.unicamp.br
|
||||
% -
|
||||
% name: J. Joao Dias Almeida
|
||||
% contact: jj (at) di.uminho.pt
|
||||
% -
|
||||
% name: Leonardo Araujo
|
||||
% contact: leolca (at) gmail.com
|
||||
% -
|
||||
% name: Aline Benevides
|
||||
% contact: benevides.aline12 (at) gmail.com
|
||||
% licence:
|
||||
% name: BSD 3-clause licence
|
||||
% url: https://opensource.org/licenses/BSD-3-Clause
|
||||
% text: >
|
||||
% Redistribution and use in source and binary forms, with or without
|
||||
% modification, are permitted provided that the following conditions
|
||||
% are met:
|
||||
% * Redistributions of source code must retain the above copyright
|
||||
% notice, this list of conditions and the following disclaimer.
|
||||
% * Redistributions in binary form must reproduce the above copyright
|
||||
% notice, this list of conditions and the following disclaimer in the
|
||||
% documentation and/or other materials provided with the
|
||||
% distribution.
|
||||
% * Neither the name of the University of Campinas, of the University
|
||||
% of Minho nor the names of its contributors may be used to endorse
|
||||
% or promote products derived from this software without specific
|
||||
% prior written permission.
|
||||
%
|
||||
% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
% "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
% LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
% A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO J. DE
|
||||
% REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
% INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
% BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
|
||||
% OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
|
||||
% AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
% LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
|
||||
% WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
% POSSIBILITY OF SUCH DAMAGE.
|
||||
% hyphenmins:
|
||||
% typesetting:
|
||||
% left: 2
|
||||
% right: 3
|
||||
% changes:
|
||||
% - Version 1.4 Release date: 13/07/2024 Leonardo Araujo and Aline Benevides
|
||||
% - Version 1.3 Release date: 12/08/2015 Pedro J. de Rezende and J. Joao Dias Almeida
|
||||
% - Version 1.2 Release date: 07/21/1996 Pedro J. de Rezende and J. Joao Dias Almeida
|
||||
% - Version 1.1 Release date: 04/12/1994 Pedro J. de Rezende
|
||||
% - Version 1.0 Release date: 02/13/1987 Pedro J. de Rezende
|
||||
% texlive:
|
||||
% synonyms:
|
||||
% - portuges
|
||||
% encoding: ec
|
||||
% babelname: portuguese
|
||||
% legacy_patterns: pthyph.tex
|
||||
% message: Portuguese hyphenation patterns
|
||||
% description: Hyphenation patterns for Portuguese in T1/EC and UTF-8 encodings.
|
||||
%
|
||||
\patterns{
|
||||
1b2l
|
||||
1b2r
|
||||
1ba
|
||||
1be
|
||||
1bi
|
||||
1bo
|
||||
1bu
|
||||
1bá
|
||||
1bâ
|
||||
1bã
|
||||
1bé
|
||||
1bí
|
||||
1bó
|
||||
1bú
|
||||
1bê
|
||||
1bõ
|
||||
1c2h
|
||||
1c2l
|
||||
1c2r
|
||||
1ca
|
||||
1ce
|
||||
1ci
|
||||
1co
|
||||
1cu
|
||||
1cá
|
||||
1câ
|
||||
1cã
|
||||
1cé
|
||||
1cí
|
||||
1có
|
||||
1cú
|
||||
1cê
|
||||
1cõ
|
||||
1ça
|
||||
1çe
|
||||
1çi
|
||||
1ço
|
||||
1çu
|
||||
1çá
|
||||
1çâ
|
||||
1çã
|
||||
1çé
|
||||
1çí
|
||||
1çó
|
||||
1çú
|
||||
1çê
|
||||
1çõ
|
||||
1d2l
|
||||
1d2r
|
||||
1da
|
||||
1de
|
||||
1di
|
||||
1do
|
||||
1du
|
||||
1dá
|
||||
1dâ
|
||||
1dã
|
||||
1dé
|
||||
1dí
|
||||
1dó
|
||||
1dú
|
||||
1dê
|
||||
1dõ
|
||||
1f2l
|
||||
1f2r
|
||||
1fa
|
||||
1fe
|
||||
1fi
|
||||
1fo
|
||||
1fu
|
||||
1fá
|
||||
1fâ
|
||||
1fã
|
||||
1fé
|
||||
1fí
|
||||
1fó
|
||||
1fú
|
||||
1fê
|
||||
1fõ
|
||||
1g2l
|
||||
1g2r
|
||||
1ga
|
||||
1ge
|
||||
1gi
|
||||
1go
|
||||
1gu
|
||||
1gu4a
|
||||
1gu4e
|
||||
1gu4i
|
||||
1gu4o
|
||||
1gá
|
||||
1gâ
|
||||
1gã
|
||||
1gé
|
||||
1gí
|
||||
1gó
|
||||
1gú
|
||||
1gê
|
||||
1gõ
|
||||
1ja
|
||||
1je
|
||||
1ji
|
||||
1jo
|
||||
1ju
|
||||
1já
|
||||
1jâ
|
||||
1jã
|
||||
1jé
|
||||
1jí
|
||||
1jó
|
||||
1jú
|
||||
1jê
|
||||
1jõ
|
||||
1k2l
|
||||
1k2r
|
||||
1ka
|
||||
1ke
|
||||
1ki
|
||||
1ko
|
||||
1ku
|
||||
1ká
|
||||
1kâ
|
||||
1kã
|
||||
1ké
|
||||
1kí
|
||||
1kó
|
||||
1kú
|
||||
1kê
|
||||
1kõ
|
||||
1l2h
|
||||
1la
|
||||
1le
|
||||
1li
|
||||
1lo
|
||||
1lu
|
||||
1lá
|
||||
1lâ
|
||||
1lã
|
||||
1lé
|
||||
1lí
|
||||
1ló
|
||||
1lú
|
||||
1lê
|
||||
1lõ
|
||||
1ma
|
||||
1me
|
||||
1mi
|
||||
1mo
|
||||
1mu
|
||||
1má
|
||||
1mâ
|
||||
1mã
|
||||
1mé
|
||||
1mí
|
||||
1mó
|
||||
1mú
|
||||
1mê
|
||||
1mõ
|
||||
1n2h
|
||||
1na
|
||||
1ne
|
||||
1ni
|
||||
1no
|
||||
1nu
|
||||
1ná
|
||||
1nâ
|
||||
1nã
|
||||
1né
|
||||
1ní
|
||||
1nó
|
||||
1nú
|
||||
1nê
|
||||
1nõ
|
||||
1p2l
|
||||
1p2r
|
||||
1pa
|
||||
1pe
|
||||
1pi
|
||||
1po
|
||||
1pu
|
||||
1pá
|
||||
1pâ
|
||||
1pã
|
||||
1pé
|
||||
1pí
|
||||
1pó
|
||||
1pú
|
||||
1pê
|
||||
1põ
|
||||
1qu4a
|
||||
1qu4e
|
||||
1qu4i
|
||||
1qu4o
|
||||
1ra
|
||||
1re
|
||||
1ri
|
||||
1ro
|
||||
1ru
|
||||
1rá
|
||||
1râ
|
||||
1rã
|
||||
1ré
|
||||
1rí
|
||||
1ró
|
||||
1rú
|
||||
1rê
|
||||
1rõ
|
||||
1sa
|
||||
1se
|
||||
1si
|
||||
1so
|
||||
1su
|
||||
1sá
|
||||
1sâ
|
||||
1sã
|
||||
1sé
|
||||
1sí
|
||||
1só
|
||||
1sú
|
||||
1sê
|
||||
1sõ
|
||||
1t2l
|
||||
1t2r
|
||||
1ta
|
||||
1te
|
||||
1ti
|
||||
1to
|
||||
1tu
|
||||
1tá
|
||||
1tâ
|
||||
1tã
|
||||
1té
|
||||
1tí
|
||||
1tó
|
||||
1tú
|
||||
1tê
|
||||
1tõ
|
||||
1v2l
|
||||
1v2r
|
||||
1va
|
||||
1ve
|
||||
1vi
|
||||
1vo
|
||||
1vu
|
||||
1vá
|
||||
1vâ
|
||||
1vã
|
||||
1vé
|
||||
1ví
|
||||
1vó
|
||||
1vú
|
||||
1vê
|
||||
1võ
|
||||
1w2l
|
||||
1w2r
|
||||
1xa
|
||||
1xe
|
||||
1xi
|
||||
1xo
|
||||
1xu
|
||||
1xá
|
||||
1xâ
|
||||
1xã
|
||||
1xé
|
||||
1xí
|
||||
1xó
|
||||
1xú
|
||||
1xê
|
||||
1xõ
|
||||
1za
|
||||
1ze
|
||||
1zi
|
||||
1zo
|
||||
1zu
|
||||
1zá
|
||||
1zâ
|
||||
1zã
|
||||
1zé
|
||||
1zí
|
||||
1zó
|
||||
1zú
|
||||
1zê
|
||||
1zõ
|
||||
a3a
|
||||
a3e
|
||||
a3o
|
||||
c3c
|
||||
e3a
|
||||
e3e
|
||||
e3o
|
||||
i3a
|
||||
i3e
|
||||
i3i
|
||||
i3o
|
||||
i3â
|
||||
i3ê
|
||||
i3ô
|
||||
o3a
|
||||
o3e
|
||||
o3o
|
||||
r3r
|
||||
s3s
|
||||
u3a
|
||||
u3e
|
||||
u3o
|
||||
u3u
|
||||
.p2si
|
||||
.p2sí
|
||||
.g2no
|
||||
.g2nó
|
||||
.g2nô
|
||||
t2c
|
||||
1p2neu
|
||||
.t2m
|
||||
.p2t
|
||||
su2b3r
|
||||
su2b3l
|
||||
.su3b4li
|
||||
a1ir.
|
||||
u1ir.
|
||||
1qu
|
||||
1vô
|
||||
1lô
|
||||
1cô
|
||||
1gô
|
||||
1bô
|
||||
1tô
|
||||
1rô
|
||||
1pô
|
||||
a1é
|
||||
a1í
|
||||
a1ó
|
||||
a1ú
|
||||
e1á
|
||||
e1â
|
||||
e1ã
|
||||
e1é
|
||||
e1ê
|
||||
e1í
|
||||
e1ó
|
||||
é1o
|
||||
e1ú
|
||||
i1á
|
||||
i1ã
|
||||
í1a
|
||||
i1é
|
||||
i1í
|
||||
i1ó
|
||||
í1o
|
||||
i1u
|
||||
i1ú
|
||||
o1á
|
||||
o1é
|
||||
o1í
|
||||
o1ó
|
||||
u1á
|
||||
u1ã
|
||||
u1â
|
||||
u1í
|
||||
ú1o
|
||||
1qu2á
|
||||
1qu2â
|
||||
1qu2í
|
||||
1gu2í
|
||||
a1ind
|
||||
a1i1nh
|
||||
e1imp
|
||||
e1inc
|
||||
e1inf
|
||||
e1ing
|
||||
e1ins
|
||||
e1int
|
||||
e1inv
|
||||
u1iz.
|
||||
a1iz.
|
||||
4a.
|
||||
4e.
|
||||
4o.
|
||||
1gu2á
|
||||
1gu2ã
|
||||
1qu2ã
|
||||
.m2n
|
||||
c2za
|
||||
.s2
|
||||
1p2seu1d
|
||||
1dô
|
||||
1fô
|
||||
1mô
|
||||
1nô
|
||||
1sô
|
||||
1zô
|
||||
tu1i
|
||||
tu2it
|
||||
tu2id
|
||||
bu1i
|
||||
nu1i
|
||||
o1in
|
||||
u1in
|
||||
su1i
|
||||
í1e
|
||||
ju1i
|
||||
fu1i
|
||||
du1i
|
||||
do1im
|
||||
au1i
|
||||
u1i1ç
|
||||
u1ê
|
||||
1gu2ê
|
||||
1qu2ê
|
||||
1çô
|
||||
u1é
|
||||
1gu2é
|
||||
1qu2é
|
||||
1xô
|
||||
a1â
|
||||
a1ã
|
||||
a1ô
|
||||
e1ô
|
||||
.ne4o
|
||||
o1ã
|
||||
o1ê
|
||||
o1im
|
||||
o2i1na
|
||||
pro1i1b
|
||||
co2ima
|
||||
.t2
|
||||
1-
|
||||
}
|
||||
\hyphenation{% Do NOT make any alterations to this list! --- PdR
|
||||
hard-ware
|
||||
soft-ware
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
# English spelling supplement: common words the public-domain Moby list (1990s) lacks.
|
||||
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
|
||||
# One lowercase base form per line, sorted. Plurals, -ed, -ing, -er, -ly… come from the
|
||||
# inflection rules in crates/proof/src/lib.rs, so list "desktop", not "desktops".
|
||||
airbag
|
||||
airfare
|
||||
artcraft
|
||||
autocomplete
|
||||
autocorrect
|
||||
autofill
|
||||
autoformat
|
||||
autosave
|
||||
backend
|
||||
bitcoin
|
||||
bitmap
|
||||
blockchain
|
||||
blog
|
||||
blogger
|
||||
bluetooth
|
||||
captcha
|
||||
caregiver
|
||||
carpool
|
||||
cellphone
|
||||
chatbot
|
||||
chatroom
|
||||
checkbox
|
||||
checklist
|
||||
childcare
|
||||
clamour
|
||||
clickable
|
||||
colourful
|
||||
colourisation
|
||||
colourise
|
||||
colourless
|
||||
configurable
|
||||
copyedit
|
||||
coronavirus
|
||||
covid
|
||||
crowdfunding
|
||||
crowdsource
|
||||
crypto
|
||||
cryptocurrency
|
||||
customizable
|
||||
customization
|
||||
cyber
|
||||
cybersecurity
|
||||
cyberspace
|
||||
database
|
||||
dataset
|
||||
daycare
|
||||
desktop
|
||||
download
|
||||
downloadable
|
||||
downsize
|
||||
dropdown
|
||||
earbud
|
||||
ebook
|
||||
ecommerce
|
||||
email
|
||||
emoji
|
||||
emoticon
|
||||
endeavour
|
||||
esports
|
||||
facebook
|
||||
favicon
|
||||
fintech
|
||||
firewall
|
||||
flowchart
|
||||
foodie
|
||||
freeware
|
||||
frontend
|
||||
fundraiser
|
||||
gameplay
|
||||
gamification
|
||||
geeky
|
||||
geolocation
|
||||
gigabit
|
||||
gigabyte
|
||||
github
|
||||
glamour
|
||||
globalization
|
||||
google
|
||||
groupthink
|
||||
hackathon
|
||||
hardcode
|
||||
hashtag
|
||||
healthcare
|
||||
homepage
|
||||
homeschool
|
||||
honour
|
||||
honourable
|
||||
honourably
|
||||
hotspot
|
||||
hyperlink
|
||||
hypertext
|
||||
inbox
|
||||
incentivize
|
||||
infographic
|
||||
instagram
|
||||
internet
|
||||
interoperability
|
||||
intranet
|
||||
ipad
|
||||
iphone
|
||||
javascript
|
||||
kickstart
|
||||
kilobyte
|
||||
laptop
|
||||
lifestyle
|
||||
linkedin
|
||||
linux
|
||||
livestream
|
||||
lockdown
|
||||
login
|
||||
logout
|
||||
lowercase
|
||||
makeover
|
||||
malware
|
||||
manoeuvrable
|
||||
megabyte
|
||||
megapixel
|
||||
meme
|
||||
metadata
|
||||
microchip
|
||||
microsoft
|
||||
middleware
|
||||
mindset
|
||||
multicultural
|
||||
multiplayer
|
||||
multitask
|
||||
nerdy
|
||||
netbook
|
||||
netflix
|
||||
newsfeed
|
||||
offboard
|
||||
offline
|
||||
onboard
|
||||
online
|
||||
onscreen
|
||||
outsource
|
||||
paediatric
|
||||
paralegal
|
||||
passcode
|
||||
petabyte
|
||||
phishing
|
||||
photobomb
|
||||
playdate
|
||||
playlist
|
||||
plugin
|
||||
podcast
|
||||
popup
|
||||
prequel
|
||||
printout
|
||||
prioritise
|
||||
prioritization
|
||||
prioritize
|
||||
privatise
|
||||
ransomware
|
||||
recyclable
|
||||
rigour
|
||||
ringtone
|
||||
roadmap
|
||||
rollout
|
||||
runtime
|
||||
scalability
|
||||
screencast
|
||||
screensaver
|
||||
screenshot
|
||||
scrollbar
|
||||
selfie
|
||||
signup
|
||||
sitemap
|
||||
skillset
|
||||
smartphone
|
||||
smartwatch
|
||||
smiley
|
||||
snowboard
|
||||
spellcheck
|
||||
spellchecker
|
||||
spreadsheet
|
||||
spyware
|
||||
stakeholder
|
||||
standup
|
||||
startup
|
||||
subfolder
|
||||
sunscreen
|
||||
superfood
|
||||
superuser
|
||||
synthesise
|
||||
tagline
|
||||
takeout
|
||||
techie
|
||||
telecommute
|
||||
teleconference
|
||||
telehealth
|
||||
telemedicine
|
||||
terabyte
|
||||
textbox
|
||||
timeline
|
||||
timestamp
|
||||
toolbar
|
||||
toolkit
|
||||
touchpad
|
||||
touchscreen
|
||||
trackball
|
||||
trackpad
|
||||
travelogue
|
||||
upcycle
|
||||
upload
|
||||
uppercase
|
||||
upsell
|
||||
upskill
|
||||
usability
|
||||
username
|
||||
videoconference
|
||||
videogame
|
||||
vlog
|
||||
vlogger
|
||||
voicemail
|
||||
walkthrough
|
||||
webcam
|
||||
webcast
|
||||
webinar
|
||||
weblog
|
||||
webmaster
|
||||
webpage
|
||||
website
|
||||
whatsapp
|
||||
whiteboard
|
||||
wifi
|
||||
wiki
|
||||
wikipedia
|
||||
wireframe
|
||||
wordcraft
|
||||
workaround
|
||||
workflow
|
||||
workforce
|
||||
workplace
|
||||
workspace
|
||||
workstation
|
||||
youtube
|
||||
@@ -1,305 +0,0 @@
|
||||
# English spelling variants: American and British forms of the same word, for the English
|
||||
# dialects (#1281, #1045). Written by WordCraft contributors from general knowledge of English
|
||||
# spelling (MIT OR Apache-2.0); no other word list copied. See ATTRIBUTION.md.
|
||||
#
|
||||
# Each line: POLICY AMERICAN BRITISH
|
||||
# POLICY is three letters, for American English (en-US), Canadian English (en-CA) and the
|
||||
# British-spelling Englishes (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…), in that order:
|
||||
# u only the American form is right there (the British one is flagged, American suggested)
|
||||
# b only the British form is right there
|
||||
# * both are right
|
||||
# Inflections (colours, centred, organising, organisation…) come from the rules in
|
||||
# crates/proof/src/en_variants.rs for -or/-our, -er/-re and -ize/-ise style pairs; other pairs
|
||||
# get only a plural, so list their other forms on lines of their own. When in doubt a form is
|
||||
# accepted: a false alarm is worse than a missed variant.
|
||||
|
||||
# -or / -our
|
||||
ubb arbor arbour
|
||||
ubb ardor ardour
|
||||
ubb armor armour
|
||||
ubb armory armoury
|
||||
ubb behavior behaviour
|
||||
ubb behavioral behavioural
|
||||
ubb candor candour
|
||||
ubb clamor clamour
|
||||
ubb color colour
|
||||
ubb demeanor demeanour
|
||||
ubb discolor discolour
|
||||
ubb dishonor dishonour
|
||||
ubb endeavor endeavour
|
||||
ubb favor favour
|
||||
ubb favorite favourite
|
||||
ubb favoritism favouritism
|
||||
ubb fervor fervour
|
||||
ubb flavor flavour
|
||||
ubb harbor harbour
|
||||
ubb honor honour
|
||||
ubb humor humour
|
||||
ubb labor labour
|
||||
ubb misbehavior misbehaviour
|
||||
ubb neighbor neighbour
|
||||
ubb neighborhood neighbourhood
|
||||
ubb neighborly neighbourly
|
||||
ubb odor odour
|
||||
ubb parlor parlour
|
||||
ubb rancor rancour
|
||||
ubb rigor rigour
|
||||
ubb rumor rumour
|
||||
ubb savior saviour
|
||||
ubb savor savour
|
||||
ubb savory savoury
|
||||
ubb splendor splendour
|
||||
ubb succor succour
|
||||
ubb tumor tumour
|
||||
ubb unfavorable unfavourable
|
||||
ubb valor valour
|
||||
ubb vapor vapour
|
||||
ubb vigor vigour
|
||||
|
||||
# -er / -re
|
||||
ubb caliber calibre
|
||||
ubb center centre
|
||||
ubb centimeter centimetre
|
||||
ubb epicenter epicentre
|
||||
ubb fiber fibre
|
||||
ubb kilometer kilometre
|
||||
ubb liter litre
|
||||
ubb luster lustre
|
||||
ubb maneuver manoeuvre
|
||||
ubb meager meagre
|
||||
ubb millimeter millimetre
|
||||
ubb miter mitre
|
||||
ubb ocher ochre
|
||||
ubb saber sabre
|
||||
ubb sepulcher sepulchre
|
||||
ubb somber sombre
|
||||
ubb specter spectre
|
||||
*bb theater theatre
|
||||
# A meter is also a measuring device in British English.
|
||||
u** meter metre
|
||||
|
||||
# -ize / -ise (British English accepts both: Oxford spelling uses -ize)
|
||||
uu* agonize agonise
|
||||
uu* apologize apologise
|
||||
uu* authorize authorise
|
||||
uu* capitalize capitalise
|
||||
uu* categorize categorise
|
||||
uu* centralize centralise
|
||||
uu* characterize characterise
|
||||
uu* civilize civilise
|
||||
uu* colonize colonise
|
||||
uu* commercialize commercialise
|
||||
uu* computerize computerise
|
||||
uu* criticize criticise
|
||||
uu* crystallize crystallise
|
||||
uu* customize customise
|
||||
uu* decentralize decentralise
|
||||
uu* demonize demonise
|
||||
uu* deodorize deodorise
|
||||
uu* destabilize destabilise
|
||||
uu* digitize digitise
|
||||
uu* dramatize dramatise
|
||||
uu* economize economise
|
||||
uu* emphasize emphasise
|
||||
uu* energize energise
|
||||
uu* equalize equalise
|
||||
uu* familiarize familiarise
|
||||
uu* fantasize fantasise
|
||||
uu* fertilize fertilise
|
||||
uu* finalize finalise
|
||||
uu* formalize formalise
|
||||
uu* fossilize fossilise
|
||||
uu* galvanize galvanise
|
||||
uu* generalize generalise
|
||||
uu* globalize globalise
|
||||
uu* harmonize harmonise
|
||||
uu* hospitalize hospitalise
|
||||
uu* humanize humanise
|
||||
uu* hypnotize hypnotise
|
||||
uu* hypothesize hypothesise
|
||||
uu* idealize idealise
|
||||
uu* idolize idolise
|
||||
uu* immobilize immobilise
|
||||
uu* immortalize immortalise
|
||||
uu* immunize immunise
|
||||
uu* industrialize industrialise
|
||||
uu* initialize initialise
|
||||
uu* italicize italicise
|
||||
uu* itemize itemise
|
||||
uu* jeopardize jeopardise
|
||||
uu* legalize legalise
|
||||
uu* legitimize legitimise
|
||||
uu* liberalize liberalise
|
||||
uu* localize localise
|
||||
uu* magnetize magnetise
|
||||
uu* marginalize marginalise
|
||||
uu* maximize maximise
|
||||
uu* mechanize mechanise
|
||||
uu* memorize memorise
|
||||
uu* mesmerize mesmerise
|
||||
uu* minimize minimise
|
||||
uu* mobilize mobilise
|
||||
uu* modernize modernise
|
||||
uu* monopolize monopolise
|
||||
uu* moralize moralise
|
||||
uu* nationalize nationalise
|
||||
uu* naturalize naturalise
|
||||
uu* neutralize neutralise
|
||||
uu* normalize normalise
|
||||
uu* optimize optimise
|
||||
uu* organize organise
|
||||
uu* ostracize ostracise
|
||||
uu* oxidize oxidise
|
||||
uu* pasteurize pasteurise
|
||||
uu* patronize patronise
|
||||
uu* penalize penalise
|
||||
uu* personalize personalise
|
||||
uu* plagiarize plagiarise
|
||||
uu* polarize polarise
|
||||
uu* popularize popularise
|
||||
uu* pressurize pressurise
|
||||
uu* prioritize prioritise
|
||||
uu* privatize privatise
|
||||
uu* publicize publicise
|
||||
uu* pulverize pulverise
|
||||
uu* randomize randomise
|
||||
uu* rationalize rationalise
|
||||
uu* realize realise
|
||||
uu* recognize recognise
|
||||
uu* reorganize reorganise
|
||||
uu* revolutionize revolutionise
|
||||
uu* satirize satirise
|
||||
uu* scrutinize scrutinise
|
||||
uu* sensitize sensitise
|
||||
uu* socialize socialise
|
||||
uu* specialize specialise
|
||||
uu* stabilize stabilise
|
||||
uu* standardize standardise
|
||||
uu* sterilize sterilise
|
||||
uu* stigmatize stigmatise
|
||||
uu* subsidize subsidise
|
||||
uu* summarize summarise
|
||||
uu* symbolize symbolise
|
||||
uu* sympathize sympathise
|
||||
uu* synchronize synchronise
|
||||
uu* synthesize synthesise
|
||||
uu* systematize systematise
|
||||
uu* tantalize tantalise
|
||||
uu* terrorize terrorise
|
||||
uu* theorize theorise
|
||||
uu* trivialize trivialise
|
||||
uu* urbanize urbanise
|
||||
uu* utilize utilise
|
||||
uu* vandalize vandalise
|
||||
uu* verbalize verbalise
|
||||
uu* victimize victimise
|
||||
uu* visualize visualise
|
||||
uu* vocalize vocalise
|
||||
uu* westernize westernise
|
||||
|
||||
# -yze / -yse
|
||||
u*b analyze analyse
|
||||
u*b catalyze catalyse
|
||||
u*b dialyze dialyse
|
||||
u*b electrolyze electrolyse
|
||||
u*b hydrolyze hydrolyse
|
||||
u*b paralyze paralyse
|
||||
u*b psychoanalyze psychoanalyse
|
||||
|
||||
# -ense / -ence
|
||||
ubb defense defence
|
||||
ubb offense offence
|
||||
ubb pretense pretence
|
||||
ubb defenseless defenceless
|
||||
# In British English "license" and "practise" are the verbs, so only American English flags.
|
||||
u** license licence
|
||||
u** practice practise
|
||||
|
||||
# Doubled l (American English accepts both)
|
||||
*bb canceled cancelled
|
||||
*bb canceling cancelling
|
||||
*bb channeled channelled
|
||||
*bb channeling channelling
|
||||
*bb counseled counselled
|
||||
*bb counseling counselling
|
||||
*bb counselor counsellor
|
||||
*bb counselors counsellors
|
||||
*bb dueling duelling
|
||||
*bb equaled equalled
|
||||
*bb equaling equalling
|
||||
*bb fueled fuelled
|
||||
*bb fueling fuelling
|
||||
*bb jeweler jeweller
|
||||
*bb jewelers jewellers
|
||||
*bb jewelry jewellery
|
||||
*bb labeled labelled
|
||||
*bb labeling labelling
|
||||
*bb leveled levelled
|
||||
*bb leveling levelling
|
||||
*bb marveled marvelled
|
||||
*bb marveling marvelling
|
||||
*bb marvelous marvellous
|
||||
*bb modeled modelled
|
||||
*bb modeling modelling
|
||||
*bb quarreled quarrelled
|
||||
*bb quarreling quarrelling
|
||||
*bb signaled signalled
|
||||
*bb signaling signalling
|
||||
*bb totaled totalled
|
||||
*bb totaling totalling
|
||||
*bb traveled travelled
|
||||
*bb traveler traveller
|
||||
*bb travelers travellers
|
||||
*bb traveling travelling
|
||||
*bb tunneled tunnelled
|
||||
*bb tunneling tunnelling
|
||||
*bb woolen woollen
|
||||
|
||||
# -og / -ogue
|
||||
*bb catalog catalogue
|
||||
*bb cataloged catalogued
|
||||
*bb cataloging cataloguing
|
||||
|
||||
# ae / oe
|
||||
u*b anemia anaemia
|
||||
u*b anemic anaemic
|
||||
u*b anesthesia anaesthesia
|
||||
u*b anesthetic anaesthetic
|
||||
**b archeology archaeology
|
||||
**b archeologist archaeologist
|
||||
u*b diarrhea diarrhoea
|
||||
u*b edema oedema
|
||||
u*b esophagus oesophagus
|
||||
u*b estrogen oestrogen
|
||||
u*b gynecologist gynaecologist
|
||||
u*b gynecology gynaecology
|
||||
u*b hematology haematology
|
||||
u*b hemoglobin haemoglobin
|
||||
u*b hemorrhage haemorrhage
|
||||
u*b leukemia leukaemia
|
||||
u*b orthopedic orthopaedic
|
||||
u*b pediatric paediatric
|
||||
u*b pediatrician paediatrician
|
||||
u*b pediatrics paediatrics
|
||||
|
||||
# Other words
|
||||
u*b aluminum aluminium
|
||||
u*b cozy cosy
|
||||
ubb mold mould
|
||||
ubb molded moulded
|
||||
ubb molding moulding
|
||||
ubb moldy mouldy
|
||||
ubb molt moult
|
||||
ubb pajamas pyjamas
|
||||
ubb plow plough
|
||||
ubb plowed ploughed
|
||||
ubb plowing ploughing
|
||||
u** program programme
|
||||
u*b skeptic sceptic
|
||||
u*b skeptical sceptical
|
||||
u*b skepticism scepticism
|
||||
ubb smolder smoulder
|
||||
ubb smoldering smouldering
|
||||
# "Tire" and "curb" are also verbs everywhere, so only American English flags.
|
||||
uu* tire tyre
|
||||
uu* curb kerb
|
||||
@@ -1,214 +0,0 @@
|
||||
# Russian spelling supplement: common words older Russian Hunspell dictionaries lack.
|
||||
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
|
||||
# One word per line: the base form, a TAB, then its declension, from which
|
||||
# crates/proof/src/ru/extra.rs makes every form:
|
||||
# m masculine noun ending in a consonant (маркетплейс, -а, -у, -ом, -е, -ы/-и, -ов/-ей…)
|
||||
# f feminine noun in -а (инфографика, -и, -е, -у, -ой…; genitive plural without ending)
|
||||
# fia feminine noun in -ия (интеграция, -ии, -ию, -ией, -ий…)
|
||||
# fs feminine noun in -ь (соцсеть, -и, -ью, -ей, -ям…)
|
||||
# nie neuter noun in -ие (согласие, -ия, -ию, -ием, -ий…)
|
||||
# no neuter noun in -о (юрлицо, -а, -у, -ом, -е; genitive plural without ending)
|
||||
# adj adjective or participle in -ый, -ий, -ой (бонусный, газоиспользующий)
|
||||
# ovat verb in -овать with its conjugation and participles (колеровать, администрировать)
|
||||
# = the forms themselves, space-separated (verbs, nouns with a fleeting vowel)
|
||||
# A word alone on its line doesn't decline (онлайн, медиа). Capitalized words are names and match
|
||||
# only when written with a capital.
|
||||
|
||||
# Commerce, marketing and the web
|
||||
маркетплейс m
|
||||
селлер m
|
||||
фулфилмент m
|
||||
ритейл m
|
||||
ритейлер m
|
||||
дистрибьютор m
|
||||
дропшиппинг m
|
||||
мерчандайзер m
|
||||
эквайринг m
|
||||
промокод m
|
||||
кешбэк m
|
||||
кэшбэк m
|
||||
лендинг m
|
||||
лид m
|
||||
тренд m
|
||||
бренд m
|
||||
брендбук m
|
||||
ребрендинг m
|
||||
таргетинг m
|
||||
геотаргетинг m
|
||||
ретаргетинг m
|
||||
таргетолог m
|
||||
маркетолог m
|
||||
копирайтер m
|
||||
рерайтер m
|
||||
контент m
|
||||
блогер m
|
||||
инфлюенсер m
|
||||
хештег m
|
||||
хэштег m
|
||||
репост m
|
||||
лайк m
|
||||
подкаст m
|
||||
стриминг m
|
||||
вебинар m
|
||||
чат m
|
||||
бот m
|
||||
чат-бот m
|
||||
стартап m
|
||||
франчайзинг m
|
||||
аутсорсинг m
|
||||
аутстаффинг m
|
||||
коворкинг m
|
||||
дедлайн m
|
||||
фидбэк m
|
||||
фидбек m
|
||||
кейс m
|
||||
скриншот m
|
||||
скринкаст m
|
||||
баннер m
|
||||
виджет m
|
||||
дашборд m
|
||||
сервис m
|
||||
сайт m
|
||||
скан m
|
||||
токен m
|
||||
аккаунт m
|
||||
логин m
|
||||
пароль m
|
||||
смартфон m
|
||||
гаджет m
|
||||
планшет m
|
||||
ноутбук m
|
||||
апгрейд m
|
||||
апдейт m
|
||||
бэкап m
|
||||
бэкенд m
|
||||
фронтенд m
|
||||
датасет m
|
||||
промпт m
|
||||
интерфейс m
|
||||
хостинг m
|
||||
домен m
|
||||
сниппет m
|
||||
трафик m
|
||||
юзер m
|
||||
стикер m
|
||||
чекбокс m
|
||||
инпут m
|
||||
плейсхолдер m
|
||||
тултип m
|
||||
гипсокартон m
|
||||
пеноплекс m
|
||||
праймер m
|
||||
колер m
|
||||
колорант m
|
||||
Яндекс m
|
||||
Битрикс m
|
||||
Вебвизор m
|
||||
Дзен m
|
||||
инфографика f
|
||||
аналитика f
|
||||
оплата f
|
||||
предоплата f
|
||||
постоплата f
|
||||
доплата f
|
||||
переплата f
|
||||
недоплата f
|
||||
маркировка = маркировка маркировки маркировке маркировку маркировкой маркировкою маркировок маркировкам маркировками маркировках
|
||||
отгрузка = отгрузка отгрузки отгрузке отгрузку отгрузкой отгрузкою отгрузок отгрузкам отгрузками отгрузках
|
||||
колеровка = колеровка колеровки колеровке колеровку колеровкой колеровкою колеровок колеровкам колеровками колеровках
|
||||
рассылка = рассылка рассылки рассылке рассылку рассылкой рассылкою рассылок рассылкам рассылками рассылках
|
||||
выгрузка = выгрузка выгрузки выгрузке выгрузку выгрузкой выгрузкою выгрузок выгрузкам выгрузками выгрузках
|
||||
интеграция fia
|
||||
пагинация fia
|
||||
госрегистрация fia
|
||||
геолокация fia
|
||||
монетизация fia
|
||||
кастомизация fia
|
||||
персонализация fia
|
||||
сегментация fia
|
||||
верификация fia
|
||||
валидация fia
|
||||
авторизация fia
|
||||
аутентификация fia
|
||||
оптимизация fia
|
||||
визуализация fia
|
||||
соцсеть fs
|
||||
согласие nie
|
||||
домовладение nie
|
||||
рождение nie
|
||||
паллетирование nie
|
||||
неисполнение nie
|
||||
непредоставление nie
|
||||
бонусный adj
|
||||
маркетинговый adj
|
||||
мотивационный adj
|
||||
гипоаллергенный adj
|
||||
тиксотропный adj
|
||||
сополимерный adj
|
||||
покупочный adj
|
||||
таргетированный adj
|
||||
брендированный adj
|
||||
кастомный adj
|
||||
онлайновый adj
|
||||
офлайновый adj
|
||||
колеруемый adj
|
||||
колерованный adj
|
||||
заколерованный adj
|
||||
газоиспользующий adj
|
||||
колеровать ovat
|
||||
заколеровать ovat
|
||||
администрировать ovat
|
||||
модерировать ovat
|
||||
отмодерировать ovat
|
||||
таргетировать ovat
|
||||
монетизировать ovat
|
||||
брендировать ovat
|
||||
кастомизировать ovat
|
||||
детектировать ovat
|
||||
верифицировать ovat
|
||||
валидировать ovat
|
||||
генерировать ovat
|
||||
сгенерировать ovat
|
||||
мониторить = мониторить мониторю мониторишь мониторит мониторим мониторите мониторят мониторил мониторила мониторили мониторя мониторинг
|
||||
гуглить = гуглить гуглю гуглишь гуглит гуглим гуглите гуглят гуглил гуглила гуглили
|
||||
скачивать = скачивать скачиваю скачиваешь скачивает скачиваем скачиваете скачивают скачивал скачивала скачивали скачивается скачиваются
|
||||
оценивание nie
|
||||
медучреждение nie
|
||||
юрлицо no
|
||||
учётка = учётка учётки учётке учётку учёткой учёток учёткам учётками учётках учетка учетки учетке учетку учеткой учеток учеткам учетками учетках
|
||||
перелинковка = перелинковка перелинковки перелинковке перелинковку перелинковкой перелинковок
|
||||
поклейка = поклейка поклейки поклейке поклейку поклейкой поклеек
|
||||
детекция fia
|
||||
досудебный adj
|
||||
категорийный adj
|
||||
оркестратор m
|
||||
оффер m
|
||||
трекер m
|
||||
бриф m
|
||||
гайд m
|
||||
монтажёр m
|
||||
тенант m
|
||||
мерчант m
|
||||
амбассадор m
|
||||
Роскомнадзор m
|
||||
Директ m
|
||||
Маркет m
|
||||
вовлечённость fs
|
||||
вовлеченность fs
|
||||
укрывистость fs
|
||||
# Words that don't decline
|
||||
онлайн
|
||||
офлайн
|
||||
оффлайн
|
||||
медиа
|
||||
демо
|
||||
франчайзи
|
||||
киноа
|
||||
смс
|
||||
пн
|
||||
вт
|
||||
ср
|
||||
чт
|
||||
пт
|
||||
сб
|
||||
вс
|
||||
@@ -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"
|
||||
@@ -160,6 +162,8 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
@@ -187,3 +191,9 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
9205108cfdced5177706264038848b68b2422d2b
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
|
||||
@@ -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): `<settings>/control-key.<instance>`. 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};
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `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]
|
||||
|
||||
+169
-1
@@ -48,6 +48,131 @@ impl LineCap {
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead's width across its line or length along it (ST_LineEndWidth and
|
||||
/// ST_LineEndLength, ECMA-376 §20.1.10.39–40): 2, 3 or 5 times the line's width, as Word draws it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArrowSize {
|
||||
Small,
|
||||
#[default]
|
||||
Medium,
|
||||
Large,
|
||||
}
|
||||
|
||||
impl ArrowSize {
|
||||
/// Read a DrawingML size (`sm`, `med`, `lg`); absent or unknown values are medium.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"sm" => Self::Small,
|
||||
"lg" => Self::Large,
|
||||
_ => Self::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DrawingML spelling.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Small => "sm",
|
||||
Self::Medium => "med",
|
||||
Self::Large => "lg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a VML width (`narrow`, `medium`, `wide`) or length (`short`, `medium`, `long`).
|
||||
pub fn from_vml(value: &str) -> Self {
|
||||
match value {
|
||||
"narrow" | "short" => Self::Small,
|
||||
"wide" | "long" => Self::Large,
|
||||
_ => Self::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many [`arrow_unit`]s the arrowhead measures.
|
||||
pub fn factor(self) -> f32 {
|
||||
match self {
|
||||
Self::Small => 2.0,
|
||||
Self::Medium => 3.0,
|
||||
Self::Large => 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of an arrowhead (ST_LineEndType, ECMA-376 §20.1.10.33), kept for saving; every kind
|
||||
/// is drawn as a triangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArrowKind {
|
||||
#[default]
|
||||
Triangle,
|
||||
Stealth,
|
||||
Diamond,
|
||||
Oval,
|
||||
Arrow,
|
||||
}
|
||||
|
||||
impl ArrowKind {
|
||||
/// Read a DrawingML end type other than `none`; unknown values are triangles.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"stealth" => Self::Stealth,
|
||||
"diamond" => Self::Diamond,
|
||||
"oval" => Self::Oval,
|
||||
"arrow" => Self::Arrow,
|
||||
_ => Self::Triangle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DrawingML spelling.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Triangle => "triangle",
|
||||
Self::Stealth => "stealth",
|
||||
Self::Diamond => "diamond",
|
||||
Self::Oval => "oval",
|
||||
Self::Arrow => "arrow",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a VML arrow (`block`, `classic`, `diamond`, `oval`, `open`) other than `none`.
|
||||
pub fn from_vml(value: &str) -> Self {
|
||||
match value {
|
||||
"classic" => Self::Stealth,
|
||||
"diamond" => Self::Diamond,
|
||||
"oval" => Self::Oval,
|
||||
"open" => Self::Arrow,
|
||||
_ => Self::Triangle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead's kind and size (`a:headEnd` / `a:tailEnd`). The default is a medium triangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArrowStyle {
|
||||
pub kind: ArrowKind,
|
||||
pub width: ArrowSize,
|
||||
pub length: ArrowSize,
|
||||
}
|
||||
|
||||
impl ArrowStyle {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// The arrowhead's length along its line and width across it, for a line `line_width` wide.
|
||||
pub fn dims(self, line_width: f32) -> (f32, f32) {
|
||||
let unit = arrow_unit(line_width);
|
||||
(self.length.factor() * unit, self.width.factor() * unit)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an arrowhead's size is a multiple of: the line's width, but at least 2 points (Word draws
|
||||
/// a thinner line's arrowheads as for a 2-point line).
|
||||
pub fn arrow_unit(line_width: f32) -> f32 {
|
||||
let w = if line_width.is_finite() { line_width.clamp(0.25, 200.0) } else { 0.75 };
|
||||
w.max(2.0)
|
||||
}
|
||||
|
||||
/// The rest of a shape's properties. Empty (the default) for most shapes.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -69,6 +194,11 @@ pub struct ShapeExtra {
|
||||
pub arrow_start: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub arrow_end: bool,
|
||||
/// The kind and size of the start and end arrowheads, when there are any.
|
||||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||||
pub start_style: ArrowStyle,
|
||||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||||
pub end_style: ArrowStyle,
|
||||
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
|
||||
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -83,6 +213,45 @@ pub struct ShapeExtra {
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
/// 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<std::sync::Arc<String>>,
|
||||
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` 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")]
|
||||
pub hr: Option<HorizontalRule>,
|
||||
}
|
||||
|
||||
/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one:
|
||||
/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the
|
||||
/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct HorizontalRule {
|
||||
pub pct: f32,
|
||||
pub align: crate::props::Align,
|
||||
/// Word's standard look (`o:hrstd`), kept for saving.
|
||||
pub standard: bool,
|
||||
}
|
||||
|
||||
impl HorizontalRule {
|
||||
/// The rule's width on a line `line` wide, for a shape `own` wide.
|
||||
pub fn width(&self, line: f32, own: f32) -> f32 {
|
||||
let line = line.max(0.0);
|
||||
if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) }
|
||||
}
|
||||
|
||||
/// Where a rule `w` wide starts on a line from `x`, `line` wide.
|
||||
pub fn left(&self, x: f32, line: f32, w: f32) -> f32 {
|
||||
match self.align {
|
||||
crate::props::Align::Center => x + (line - w) / 2.0,
|
||||
crate::props::Align::Right => x + line - w,
|
||||
_ => x,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
@@ -221,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();
|
||||
|
||||
@@ -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<f32>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -210,7 +213,9 @@ impl Document {
|
||||
if !parent.len().is_multiple_of(3) {
|
||||
return None;
|
||||
}
|
||||
crate::work::steps(1);
|
||||
for ch in parent.chunks(3) {
|
||||
crate::work::steps(1);
|
||||
let [b, r, c] = ch else { return None };
|
||||
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
|
||||
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
|
||||
@@ -734,6 +739,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<Block>| {
|
||||
let mut any = false;
|
||||
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
|
||||
any
|
||||
};
|
||||
let as_read: Vec<u32> = 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<crate::kept::Moved> = 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) {
|
||||
|
||||
+370
-2
@@ -205,11 +205,15 @@ struct BoxBudget {
|
||||
costs: BTreeMap<u32, usize>,
|
||||
/// Boxes left without a story because the paste's budget couldn't hold theirs.
|
||||
skipped: usize,
|
||||
/// A box the fragment has no story for may show this document's story of that id (a
|
||||
/// fragment copied from this document without its parts). Off for another document's
|
||||
/// fragment, whose ids mean nothing here.
|
||||
local: bool,
|
||||
}
|
||||
|
||||
impl BoxBudget {
|
||||
fn new() -> BoxBudget {
|
||||
BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 }
|
||||
BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0, local: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +228,11 @@ fn block_cost(b: &Block) -> (usize, bool) {
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
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)
|
||||
@@ -385,6 +394,167 @@ fn restyle_block(b: &mut Block, from: &str, to: &Option<String>, depth: usize) -
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Document::adopt_with`] renumbers in another document's content: media keys, lists,
|
||||
/// note and comment stories, comment ids, text boxes (one it doesn't bring is dropped), revisions
|
||||
/// (after ours, at `rev`; an id past the `revs` it brings is dropped) and styles (those kept under new ids; unstyled paragraphs take
|
||||
/// `default_style` when it's given).
|
||||
struct Relink<'a> {
|
||||
media: &'a BTreeMap<String, String>,
|
||||
nums: &'a BTreeMap<u32, u32>,
|
||||
notes: &'a BTreeMap<u32, u32>,
|
||||
comments: &'a BTreeMap<u32, u32>,
|
||||
boxes: &'a BTreeMap<u32, Part>,
|
||||
/// Added to permission range ids: ours come first.
|
||||
perms: u32,
|
||||
rev: u32,
|
||||
revs: u32,
|
||||
styles: &'a BTreeMap<String, String>,
|
||||
default_style: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Relink<'_> {
|
||||
fn block(&self, b: &mut Block) {
|
||||
// Its own stack, like `each_para_mut`: tables however deeply nested are relinked (#1339).
|
||||
let mut todo = vec![b];
|
||||
while let Some(b) = todo.pop() {
|
||||
match b {
|
||||
Block::Para(p) => self.para(p),
|
||||
Block::Table(t) => {
|
||||
self.style(&mut t.props.style);
|
||||
self.shift_change(&mut t.props.fmt_change);
|
||||
if let Some(c) = t.props.fmt_change.as_mut() {
|
||||
self.style(&mut c.old.style);
|
||||
}
|
||||
for row in t.rows.iter_mut() {
|
||||
self.shift_change(&mut row.props.fmt_change);
|
||||
for cell in row.cells.iter_mut() {
|
||||
self.shift_change(&mut cell.props.fmt_change);
|
||||
todo.extend(cell.blocks.iter_mut().map(Arc::make_mut));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn style(&self, id: &mut Option<String>) {
|
||||
if let Some(new) = id.as_ref().and_then(|i| self.styles.get(i)) {
|
||||
*id = Some(new.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's style: unstyled ones take `default_style` when it's given.
|
||||
fn para_style(&self, id: &mut Option<String>) {
|
||||
match (&*id, self.default_style) {
|
||||
(None, Some(d)) => *id = Some(d.to_string()),
|
||||
_ => self.style(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn num(&self, n: &mut Option<crate::props::NumRef>) {
|
||||
if let Some(n) = n.as_mut()
|
||||
&& n.num != 0
|
||||
{
|
||||
n.num = self.nums.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Our id for the other document's revision `r`, if it has one.
|
||||
fn rev_id(&self, r: u32) -> Option<u32> {
|
||||
if r < self.revs { self.rev.checked_add(r) } else { None }
|
||||
}
|
||||
|
||||
fn shift_change<T>(&self, c: &mut Option<Box<crate::props::PropChange<T>>>) {
|
||||
match (c.as_ref().and_then(|c| self.rev_id(c.rev)), c.as_mut()) {
|
||||
(Some(v), Some(x)) => x.rev = v,
|
||||
_ => *c = None,
|
||||
}
|
||||
}
|
||||
|
||||
fn para(&self, p: &mut Paragraph) {
|
||||
self.num(&mut p.props.numbering);
|
||||
self.para_style(&mut p.props.style);
|
||||
for r in &mut p.runs {
|
||||
self.style(&mut r.props.style);
|
||||
}
|
||||
self.style(&mut p.mark.style);
|
||||
for c in p.runs.iter_mut().map(|r| &mut r.props).chain([&mut p.mark]) {
|
||||
c.ins = c.ins.and_then(|r| self.rev_id(r));
|
||||
c.del = c.del.and_then(|r| self.rev_id(r));
|
||||
self.shift_change(&mut c.fmt_change);
|
||||
// A tracked change's old formatting names styles and lists as the live one does.
|
||||
if let Some(f) = c.fmt_change.as_mut() {
|
||||
self.style(&mut f.old.style);
|
||||
}
|
||||
}
|
||||
self.shift_change(&mut p.props.fmt_change);
|
||||
if let Some(f) = p.props.fmt_change.as_mut() {
|
||||
self.para_style(&mut f.old.style);
|
||||
self.num(&mut f.old.numbering);
|
||||
}
|
||||
if let Some(s) = p.section.as_deref_mut() {
|
||||
self.shift_change(&mut s.fmt_change);
|
||||
}
|
||||
match (p.props.num_change.as_ref().and_then(|c| self.rev_id(c.rev)), p.props.num_change.as_mut()) {
|
||||
(Some(v), Some(c)) => c.rev = v,
|
||||
_ => p.props.num_change = None,
|
||||
}
|
||||
for o in &mut p.objects {
|
||||
self.object(o, 0);
|
||||
}
|
||||
p.touch();
|
||||
}
|
||||
|
||||
fn object(&self, o: &mut InlineObject, depth: usize) {
|
||||
// An id that names nothing here becomes one that names nothing at all.
|
||||
let map = |m: &BTreeMap<u32, u32>, id: &mut u32| *id = m.get(id).copied().unwrap_or(u32::MAX);
|
||||
match o {
|
||||
InlineObject::Image { media, .. } => {
|
||||
if let Some(k) = self.media.get(media.as_str()) {
|
||||
*media = k.clone();
|
||||
}
|
||||
}
|
||||
InlineObject::Graphic { graphic, .. } => {
|
||||
let mapped = |m: &str| self.media.get(m).filter(|k| k.as_str() != m);
|
||||
if graphic.items.iter().any(|i| matches!(i, crate::graphic::GraphicItem::Image { media, .. } if mapped(media).is_some())) {
|
||||
for i in &mut Arc::make_mut(graphic).items {
|
||||
if let crate::graphic::GraphicItem::Image { media, .. } = i
|
||||
&& let Some(k) = self.media.get(media.as_str())
|
||||
{
|
||||
*media = k.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => children.iter_mut().for_each(|c| self.object(&mut c.obj, depth + 1)),
|
||||
InlineObject::NoteRef { id, .. } => map(self.notes, id),
|
||||
InlineObject::CommentStart { id } | InlineObject::CommentEnd { id } => map(self.comments, id),
|
||||
InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => *id = id.saturating_add(self.perms),
|
||||
_ => {}
|
||||
}
|
||||
for story in o.text_box_slots() {
|
||||
if story.is_some_and(|id| !self.boxes.contains_key(&id)) {
|
||||
*story = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One past the highest permission range id in `blocks`.
|
||||
fn perm_end<'a>(blocks: impl Iterator<Item = &'a Arc<Block>>) -> u32 {
|
||||
let mut end = 0u32;
|
||||
for b in blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for o in &p.objects {
|
||||
if let InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } = o {
|
||||
end = end.max(id.saturating_add(1));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
/// Text box story ids of a paragraph's shapes.
|
||||
fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
|
||||
p.objects.iter().flat_map(InlineObject::text_boxes)
|
||||
@@ -439,8 +609,10 @@ impl Document {
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
crate::work::steps(1);
|
||||
let end = start.saturating_add(r.len);
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
crate::work::steps(1);
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
@@ -862,6 +1034,186 @@ impl Document {
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// Another document's body as a fragment of this one, to insert: the media, lists, styles,
|
||||
/// notes, comments and tracked changes it refers to come along (lists, notes, comments and
|
||||
/// revisions renumbered so they can't collide with ours; a style we already have by id stays
|
||||
/// ours), and its text boxes ride in [`Fragment::parts`].
|
||||
pub fn adopt(&mut self, src: Document) -> Fragment {
|
||||
self.adopt_with(src, false)
|
||||
}
|
||||
|
||||
/// [`Self::adopt`]; with `keep_styles` its content keeps its own formatting (Word's
|
||||
/// `w:matchSrc`): a style whose id we already have comes along under a new id, and its
|
||||
/// document defaults go into its base styles, which its unstyled paragraphs then use.
|
||||
pub fn adopt_with(&mut self, src: Document, keep_styles: bool) -> Fragment {
|
||||
self.adopt_in(src, keep_styles, &mut 0)
|
||||
}
|
||||
|
||||
/// [`Self::adopt_with`], its permission range ids from at least `perms` on (and past ours);
|
||||
/// `perms` moves past them.
|
||||
fn adopt_in(&mut self, mut src: Document, keep_styles: bool, perms: &mut u32) -> Fragment {
|
||||
let mut renamed = BTreeMap::new();
|
||||
let mut default_style = None;
|
||||
if keep_styles {
|
||||
if src.styles.get("Normal").is_none() {
|
||||
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();
|
||||
let room = src.styles.styles.len().saturating_mul(2).saturating_add(self.styles.styles.len()).saturating_add(1);
|
||||
for st in &src.styles.styles {
|
||||
if self.styles.get(&st.id).is_some() {
|
||||
let fresh = (1..=room)
|
||||
.map(|n| format!("{}{n}", st.id))
|
||||
.find(|id| self.styles.get(id).is_none() && src.styles.get(id).is_none() && !given.contains(id));
|
||||
if let Some(fresh) = fresh {
|
||||
given.insert(fresh.clone());
|
||||
renamed.insert(st.id.clone(), fresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
default_style = Some(renamed.get("Normal").cloned().unwrap_or_else(|| "Normal".into()));
|
||||
}
|
||||
let restyle = |id: &mut Option<String>| {
|
||||
if let Some(new) = id.as_ref().and_then(|i| renamed.get(i)) {
|
||||
*id = Some(new.clone());
|
||||
}
|
||||
};
|
||||
let mut media = BTreeMap::new();
|
||||
for (key, data) in &src.media {
|
||||
let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
|
||||
media.insert(key.clone(), self.add_media(data.to_vec(), ext));
|
||||
}
|
||||
let next_abs = self.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
|
||||
let mut abs_map = BTreeMap::new();
|
||||
for (i, a) in src.numbering.abstracts.iter().enumerate() {
|
||||
let id = next_abs.saturating_add(i as u32);
|
||||
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));
|
||||
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();
|
||||
for (i, n) in src.numbering.nums.iter().enumerate() {
|
||||
let id = next_num.saturating_add(i as u32);
|
||||
nums.insert(n.id, id);
|
||||
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));
|
||||
Arc::make_mut(&mut self.numbering).nums.push(n);
|
||||
}
|
||||
let renumber = |n: &mut Option<crate::props::NumRef>| {
|
||||
if let Some(n) = n.as_mut()
|
||||
&& n.num != 0
|
||||
{
|
||||
n.num = nums.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
};
|
||||
// What a style in the other document doesn't set comes from its defaults there, and
|
||||
// Word's own (no space, single lines) where those don't say.
|
||||
let mut para_defaults = src.styles.default_para.clone();
|
||||
para_defaults.space_before.get_or_insert(0.0);
|
||||
para_defaults.space_after.get_or_insert(0.0);
|
||||
para_defaults.line_spacing.get_or_insert(crate::props::LineSpacing::Multiple(1.0));
|
||||
for st in &src.styles.styles {
|
||||
let mut st = st.clone();
|
||||
// Our defaults stay ours.
|
||||
st.default = false;
|
||||
if keep_styles {
|
||||
if let Some(new) = renamed.get(&st.id) {
|
||||
st.name = format!("{} ({new})", st.name);
|
||||
st.id = new.clone();
|
||||
}
|
||||
restyle(&mut st.based_on);
|
||||
restyle(&mut st.next);
|
||||
restyle(&mut st.linked);
|
||||
// A character style's runs get the defaults through their paragraph's style.
|
||||
if st.based_on.is_none() && st.kind != crate::styles::StyleKind::Character {
|
||||
let mut chr = src.styles.default_chr.clone();
|
||||
chr.overlay(&st.chr);
|
||||
st.chr = chr;
|
||||
let mut para = para_defaults.clone();
|
||||
para.overlay(&st.para);
|
||||
st.para = para;
|
||||
}
|
||||
}
|
||||
if self.styles.get(&st.id).is_none() {
|
||||
renumber(&mut st.para.numbering);
|
||||
Arc::make_mut(&mut self.styles).upsert(st);
|
||||
}
|
||||
}
|
||||
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
|
||||
self.revisions.extend(src.revisions.iter().cloned());
|
||||
// Notes and comments get stories (and comments ids) of their own here; their text is
|
||||
// relinked below, once every new id is known.
|
||||
let mut notes = BTreeMap::new();
|
||||
for (id, part) in src.parts.iter().filter(|(_, p)| matches!(p.kind, PartKind::Footnote | PartKind::Endnote)) {
|
||||
notes.insert(*id, self.add_part(part.kind, Vec::new()));
|
||||
}
|
||||
let mut comments = BTreeMap::new();
|
||||
let mut next_comment = self.comments.keys().next_back().map_or(0, |k| k.saturating_add(1));
|
||||
for (id, c) in &src.comments {
|
||||
if src.parts.get(&c.part).is_some_and(|p| p.kind == PartKind::Comment) {
|
||||
notes.insert(c.part, self.add_part(PartKind::Comment, Vec::new()));
|
||||
comments.insert(*id, next_comment);
|
||||
next_comment = next_comment.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let boxes: BTreeMap<u32, Part> = src.parts.iter().filter(|(_, p)| p.kind == PartKind::TextBox).map(|(k, p)| (*k, p.clone())).collect();
|
||||
// Its permission ranges are numbered after ours, so no end of theirs closes one of ours.
|
||||
let first_perm = perm_end(self.body.iter().chain(self.parts.values().flat_map(|p| p.blocks.iter()))).max(*perms);
|
||||
*perms = first_perm.saturating_add(perm_end(src.body.iter().chain(src.parts.values().flat_map(|p| p.blocks.iter()))));
|
||||
let link = Relink {
|
||||
media: &media,
|
||||
nums: &nums,
|
||||
notes: ¬es,
|
||||
comments: &comments,
|
||||
boxes: &boxes,
|
||||
perms: first_perm,
|
||||
rev,
|
||||
revs: u32::try_from(src.revisions.len()).unwrap_or(u32::MAX),
|
||||
styles: &renamed,
|
||||
default_style: default_style.as_deref(),
|
||||
};
|
||||
let mut parts = boxes.clone();
|
||||
for part in parts.values_mut() {
|
||||
part.blocks.iter_mut().for_each(|b| link.block(Arc::make_mut(b)));
|
||||
}
|
||||
// A note's or comment's text boxes get stories here now; the body's go with the fragment.
|
||||
let mut budget = BoxBudget::new();
|
||||
for (old, new) in ¬es {
|
||||
let Some(part) = src.parts.get(old) else { continue };
|
||||
let mut blocks: Vec<Block> = part.blocks.iter().map(|b| (**b).clone()).collect();
|
||||
blocks.iter_mut().for_each(|b| link.block(b));
|
||||
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
if let Some(p) = self.parts.get_mut(new) {
|
||||
p.blocks = blocks.into_iter().map(Arc::new).collect();
|
||||
}
|
||||
}
|
||||
for (id, c) in &src.comments {
|
||||
if let (Some(new), Some(part)) = (comments.get(id), notes.get(&c.part)) {
|
||||
let parent = c.parent.and_then(|p| comments.get(&p).copied());
|
||||
// Its durable id is unique in its own document only: the writer gives the copy one.
|
||||
self.comments.insert(*new, crate::Comment { parent, part: *part, durable_id: None, ..c.clone() });
|
||||
}
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
blocks.iter_mut().for_each(|b| link.block(b));
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its
|
||||
/// text boxes already have stories of their own here. `perms` is the first permission range
|
||||
/// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past
|
||||
/// the adopted ones.
|
||||
pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec<Block> {
|
||||
let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms);
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Copies of the text box stories shapes in `blocks` show, nested boxes included.
|
||||
fn text_box_parts(&self, blocks: &[Block]) -> BTreeMap<u32, Part> {
|
||||
let mut out = BTreeMap::new();
|
||||
@@ -971,7 +1323,7 @@ impl Document {
|
||||
budget.skipped = budget.skipped.saturating_add(1);
|
||||
return None;
|
||||
}
|
||||
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?;
|
||||
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| budget.local && p.kind == PartKind::TextBox))?;
|
||||
// Charge what the copy really allocates before making any of it: the block slots, and the
|
||||
// blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many
|
||||
// boxes show them, so they cost nothing more. Worked out once per paste.
|
||||
@@ -1012,6 +1364,22 @@ impl Document {
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The blocks of `frag`, a fragment of another document, with every text box given a new
|
||||
/// story of its own here copied from the fragment's parts (as a paste does), to place them
|
||||
/// in this document; a box the fragment has no story for keeps none.
|
||||
pub fn adopt_fragment(&mut self, frag: &Fragment) -> Vec<Block> {
|
||||
let mut blocks = frag.blocks.clone();
|
||||
let mut budget = BoxBudget { local: false, ..BoxBudget::new() };
|
||||
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
|
||||
if budget.skipped > 0 {
|
||||
log::warn!(
|
||||
"{} text boxes are left empty; their stories are past what one copy brings ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)",
|
||||
budget.skipped
|
||||
);
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Insert a fragment at `pos`; returns the position after the pasted content.
|
||||
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
|
||||
// Tables that would nest deeper than the limit here become their paragraphs (#1596).
|
||||
|
||||
@@ -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::<usize>();
|
||||
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)));
|
||||
@@ -664,3 +684,230 @@ fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
|
||||
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
|
||||
}
|
||||
#[test]
|
||||
fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
use crate::para::NoteKind;
|
||||
use crate::{Comment, Revision, RevisionKind};
|
||||
// Ours already has a footnote, a comment, a list and a revision with the ids the other's use.
|
||||
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 = 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 = 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 });
|
||||
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);
|
||||
// An insertion naming a revision it doesn't have.
|
||||
p.runs[0].props.ins = Some(7);
|
||||
let props = p.props_of_char(0).clone();
|
||||
p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let new_list = p.props.numbering.unwrap().num;
|
||||
assert_ne!(new_list, our_list);
|
||||
assert!(ours.numbering.num(new_list).is_some());
|
||||
assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list));
|
||||
// Their deletion is their revision, after ours.
|
||||
let del = p.runs.iter().find_map(|r| r.props.del).unwrap();
|
||||
assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete));
|
||||
assert!(p.runs.iter().all(|r| r.props.ins.is_none()));
|
||||
// Their note and comment come along under ids of their own.
|
||||
let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o {
|
||||
InlineObject::NoteRef { id, .. } => (Some(*id), c),
|
||||
InlineObject::CommentStart { id } => (n, Some(*id)),
|
||||
_ => (n, c),
|
||||
});
|
||||
let text = |id: u32| {
|
||||
ours.parts
|
||||
.get(&id)
|
||||
.map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::<String>())
|
||||
};
|
||||
assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note"));
|
||||
let comment = ours.comments.get(&comment_id.unwrap()).unwrap();
|
||||
assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment")));
|
||||
// Its durable id is the other document's: the writer gives the copy one of its own.
|
||||
assert_eq!(comment.durable_id, None);
|
||||
assert_eq!(text(our_note).as_deref(), Some("our note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 = 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() };
|
||||
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());
|
||||
|
||||
let frag = ours.adopt_with(theirs, true);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
assert_eq!(ours.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
|
||||
use crate::styles::Style;
|
||||
let mut ours = doc(&["ours"]);
|
||||
Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
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 = 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();
|
||||
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);
|
||||
let copied = &ours.numbering.abstracts[before..];
|
||||
let styles: Vec<_> = copied
|
||||
.iter()
|
||||
.flat_map(|a| a.levels.iter())
|
||||
.chain(ours.numbering.nums.iter().flat_map(|n| n.level_overrides.iter().map(|(_, l)| l)))
|
||||
.filter_map(|l| l.style.clone())
|
||||
.collect();
|
||||
assert_eq!(styles.len(), 2, "{styles:?}");
|
||||
for st in &styles {
|
||||
assert_ne!(st, "Heading1");
|
||||
assert!(ours.styles.get(st).is_some(), "{st}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_section_breaks_tracked_change_names_its_revision() {
|
||||
use crate::{Revision, RevisionKind, SectionProps};
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() }));
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev);
|
||||
assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
|
||||
use crate::props::{NumRef, ParaProps, PropChange};
|
||||
use crate::{Revision, RevisionKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
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 = 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.
|
||||
p.props.style = Some("Quote".into());
|
||||
p.props.numbering = Some(NumRef { num: list, level: 0 });
|
||||
let old = ParaProps { style: Some("Quote".into()), numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() };
|
||||
p.props.fmt_change = PropChange::boxed(0, old);
|
||||
p.runs[0].props.style = Some("Strong".into());
|
||||
p.runs[0].props.fmt_change = PropChange::boxed(0, CharProps { style: Some("Strong".into()), ..CharProps::default() });
|
||||
|
||||
let frag = ours.adopt_with(theirs, keep);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let old = &p.props.fmt_change.as_ref().unwrap().old;
|
||||
assert_eq!((&old.style, old.numbering), (&p.props.style, p.props.numbering), "keep_styles {keep}");
|
||||
assert_ne!(old.numbering.map(|n| n.num), Some(our_list));
|
||||
let run = &p.runs[0].props;
|
||||
assert_eq!(run.fmt_change.as_ref().unwrap().old.style, run.style, "keep_styles {keep}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_styles_never_become_our_defaults() {
|
||||
use crate::styles::{Style, StyleKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_permission_ranges_keep_ids_of_their_own() {
|
||||
let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() };
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap();
|
||||
// Theirs: a closed range and one left open, numbered from 0 like ours.
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let ids: Vec<u32> = p
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(!ids.contains(&0), "{ids:?}");
|
||||
assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}");
|
||||
assert_ne!(ids[1], ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_notes_text_box_gets_a_story_of_its_own() {
|
||||
use crate::para::NoteKind;
|
||||
// Ours has a text box under the id their note's text box has.
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.add_part(PartKind::Footnote, Vec::new());
|
||||
let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box");
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]);
|
||||
let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]);
|
||||
assert_eq!(their_box, our_box);
|
||||
theirs
|
||||
.insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default())
|
||||
.unwrap();
|
||||
theirs
|
||||
.insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default())
|
||||
.unwrap();
|
||||
|
||||
let blocks = ours.adopt(theirs).blocks;
|
||||
let Some(Block::Para(p)) = blocks.first() else { panic!() };
|
||||
let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap();
|
||||
let story = ours.parts[&new_note]
|
||||
.blocks
|
||||
.iter()
|
||||
.find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None })
|
||||
.unwrap();
|
||||
assert_ne!(story, our_box);
|
||||
let part = &ours.parts[&story];
|
||||
assert_eq!(part.kind, PartKind::TextBox);
|
||||
assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box"));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,17 @@ pub struct Embedded {
|
||||
pub refs: Vec<EmbeddedRef>,
|
||||
}
|
||||
|
||||
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 = "<wordcraft:odf-object";
|
||||
|
||||
/// Is it an ODF embedded object (a spreadsheet in an ODT), not Word markup?
|
||||
pub fn is_odf_object(&self) -> 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 {
|
||||
|
||||
@@ -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<Arc<KeptXml>>);
|
||||
|
||||
/// 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<u64, Arc<str>>,
|
||||
/// The same for the body's tables ([`table_fingerprint`]).
|
||||
pub tables: HashMap<u64, Arc<str>>,
|
||||
/// While reading: the markup of the body's `n`th table, until [`Kept::seal`].
|
||||
pub pending_tables: Vec<(usize, Arc<str>)>,
|
||||
/// 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<u64, (Vec<Arc<str>>, u32)>,
|
||||
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
|
||||
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, 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<Arc<str>>, 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<u64, (Vec<Arc<str>>, u32)>,
|
||||
/// While reading: those of the body's `n`th table, until `seal_kept`.
|
||||
pub pending_table_wraps: Vec<(usize, (Vec<Arc<str>>, 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<u64, (usize, u64, Arc<str>)>,
|
||||
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
|
||||
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
|
||||
/// 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<u64, (u32, u32)>,
|
||||
/// While reading: the same by paragraph `rev`, until `seal_kept`.
|
||||
pub pending_groups: HashMap<u64, (u32, u32)>,
|
||||
/// 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<u32, u64>,
|
||||
/// 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<u32, u64>,
|
||||
/// 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<String, Arc<str>>,
|
||||
/// While reading: paragraph [`Paragraph::rev`] → its markup, until [`Kept::seal`] turns
|
||||
/// them into fingerprints.
|
||||
pub pending: HashMap<u64, Arc<str>>,
|
||||
/// Bytes kept so far.
|
||||
pub bytes: usize,
|
||||
/// The keys of [`KeptXml::defs`] in the order the file had them.
|
||||
pub order: Vec<String>,
|
||||
/// Files of the package kept definitions refer to (a style's fill picture), by path.
|
||||
pub files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// 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<u64>,
|
||||
/// 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<u64>,
|
||||
/// 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<Option<String>>,
|
||||
}
|
||||
|
||||
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<Item = &Paragraph>) {
|
||||
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<str>> {
|
||||
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<str>> {
|
||||
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("<office:annotation ").count() + xml.matches("<office:annotation>").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<Arc<str>>, 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<Arc<str>>, 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<str>)> {
|
||||
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<str>) {
|
||||
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<Arc<str>>, 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<Arc<str>>, 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<str>) {
|
||||
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<str>) {
|
||||
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<str>) {
|
||||
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<u8>) {
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
/// 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<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
|
||||
pages: Option<u64>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`replies`] was built (tests).
|
||||
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
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<R>(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<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
|
||||
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<HashMap<u32, Vec<u32>>> {
|
||||
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<u32, Vec<u32>> {
|
||||
#[cfg(test)]
|
||||
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
|
||||
let mut out: HashMap<u32, Vec<u32>> = 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<u32, Vec<u32>>) -> (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<Item = &'a Paragraph>) -> 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<Vec<&Paragraph>> = 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<u64> = 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<u32, u64> = 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<u32, u64> =
|
||||
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<u64, usize> = 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<u64, usize> = 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("<text:p>Hello</text:p>"));
|
||||
}
|
||||
kept.seal(&mut std::iter::once(&p));
|
||||
let k = kept.of("odt").unwrap();
|
||||
assert_eq!(k.para(&p).map(|x| &**x), Some("<text:p>Hello</text:p>"));
|
||||
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("<text:p>Hi</text:p>"));
|
||||
}
|
||||
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<str> = 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("<text:p>Body</text:p>"));
|
||||
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::<Vec<_>>().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 = "<text:p><office:annotation>x</office:annotation></text:p>";
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -120,14 +120,57 @@ fn attr_in(tag: &str) -> Option<String> {
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
fn unescape(mut s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
while let Some((text, rest)) = s.split_once('&') {
|
||||
out.push_str(text);
|
||||
if let Some(end) = rest.find(['&', ';'])
|
||||
&& rest.as_bytes()[end] == b';'
|
||||
{
|
||||
let entity = &rest[..end];
|
||||
let decoded = match entity {
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"amp" => Some('&'),
|
||||
_ => entity.strip_prefix('#').and_then(|n| {
|
||||
let value = if let Some(hex) = n.strip_prefix('x') {
|
||||
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
|
||||
} else {
|
||||
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
|
||||
}?;
|
||||
char::from_u32(value)
|
||||
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
|
||||
}),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
s = &rest[end + 1..];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push('&');
|
||||
s = rest;
|
||||
}
|
||||
out.push_str(s);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn drop_down_entities_decode_once_including_xml_whitespace() {
|
||||
let xml = r#"<w:ffData><w:name w:val="Name"/><w:ddList><w:listEntry w:val="A B	C D😀&#10;<>"'"/></w:ddList></w:ffData>"#;
|
||||
let f = LegacyForm::parse(xml).unwrap();
|
||||
assert_eq!(f.name(), "Name");
|
||||
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀 <>\"'"));
|
||||
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="���&#-1;&#xZZ;&unknown;&"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("���&#-1;&#xZZ;&unknown;&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_boxes_text_fields_and_drop_downs() {
|
||||
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
|
||||
|
||||
+19
-7
@@ -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,7 +49,9 @@ pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod work;
|
||||
pub mod wrap;
|
||||
pub mod zip_guard;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -83,11 +87,11 @@ 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;
|
||||
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
|
||||
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -97,8 +101,12 @@ pub struct Document {
|
||||
/// Properties of the last section (OOXML's body-level `w:sectPr`).
|
||||
pub last_section: SectionProps,
|
||||
pub parts: BTreeMap<u32, Part>,
|
||||
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<StyleSheet>,
|
||||
/// Shared like [`Document::styles`].
|
||||
pub numbering: Arc<Numbering>,
|
||||
pub comments: BTreeMap<u32, Comment>,
|
||||
pub revisions: Vec<Revision>,
|
||||
pub settings: Settings,
|
||||
@@ -126,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)]
|
||||
|
||||
+114
-2
@@ -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<String> {
|
||||
(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<String> {
|
||||
// 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"] {
|
||||
|
||||
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
|
||||
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
|
||||
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
|
||||
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
|
||||
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
/// Trim spaces and single-character currency signs from either end.
|
||||
fn trim_currency(s: &str) -> &str {
|
||||
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
|
||||
}
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
|
||||
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
|
||||
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
let mut s = trim_currency(s);
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some(rest) =
|
||||
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
|
||||
{
|
||||
s = trim_currency(rest);
|
||||
}
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
@@ -137,6 +156,7 @@ mod tests {
|
||||
assert_eq!(parse_number("1.5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
|
||||
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
|
||||
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
|
||||
assert_eq!(parse_number("abc", pl), None);
|
||||
assert_eq!(parse_number("1,2,3", pl), None);
|
||||
// From review: ordinals, a leading zero, percentages and an en dash minus.
|
||||
@@ -174,11 +194,40 @@ mod tests {
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balboa_numbers() {
|
||||
let en = Some("en-US");
|
||||
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
|
||||
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
|
||||
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
|
||||
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
|
||||
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
|
||||
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
|
||||
// The sign has no space of its own in Word: touching the number is text.
|
||||
assert_eq!(parse_number("B/.1,234.50", en), None);
|
||||
assert_eq!(parse_number("1,234.50B/.", en), None);
|
||||
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
|
||||
assert_eq!(parse_number("B/.", en), None);
|
||||
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
|
||||
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
|
||||
// the middle is not a sign.
|
||||
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
|
||||
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
|
||||
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
|
||||
assert_eq!(parse_number("B/. 1/.5", en), None);
|
||||
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
|
||||
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
|
||||
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
|
||||
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
|
||||
assert_eq!(parse_number("$1,250", en), Some(1250.0));
|
||||
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
|
||||
assert_eq!(parse_number("1.5", en), Some(1.5));
|
||||
assert_eq!(parse_number("12", None), Some(12.0));
|
||||
assert_eq!(parse_number(".5", None), Some(0.5));
|
||||
|
||||
@@ -16,6 +16,12 @@ pub type Arg = Vec<MNode>;
|
||||
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
|
||||
pub const MAX_MATRIX_COLS: usize = 64;
|
||||
|
||||
/// Most matrix rows kept.
|
||||
pub const MAX_MATRIX_ROWS: usize = 1000;
|
||||
|
||||
/// An equation's structure, plus what's needed to write it back.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct Math {
|
||||
|
||||
+191
-32
@@ -294,6 +294,14 @@ pub struct Counters {
|
||||
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
|
||||
/// level starts at 0).
|
||||
flags: HashMap<u32, [LevelFlags; 9]>,
|
||||
/// The last explicitly rendered label and template at each abstract-list level.
|
||||
labels: HashMap<(u32, u8), CountedLabel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CountedLabel {
|
||||
label: Option<String>,
|
||||
levels: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
@@ -320,6 +328,7 @@ impl Counters {
|
||||
let def = level_def(lv)?.clone();
|
||||
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
|
||||
if def.format == NumFormat::None {
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
@@ -338,6 +347,23 @@ impl Counters {
|
||||
*f = LevelFlags { started: false, first: Some(*start) };
|
||||
}
|
||||
}
|
||||
// A level above that hasn't counted yet counts its first number here, as in Word: after
|
||||
// 1.1 and 1.2 with no level-1 item before them, the next level-1 item is 2, whether or not
|
||||
// the deeper level's text shows it.
|
||||
for (k, (v, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().take(lv) {
|
||||
if f.started {
|
||||
continue;
|
||||
}
|
||||
let start = match self.seen.entry((num, k as u8)) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(true);
|
||||
n.start_overrides.iter().find(|(l, _)| *l as usize == k).map(|(_, s)| *s)
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(_) => None,
|
||||
};
|
||||
*v = start.or(f.first).unwrap_or_else(|| level_def(k).map_or(1, |l| l.start));
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
}
|
||||
if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
|
||||
*v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
@@ -349,42 +375,101 @@ impl Counters {
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
self.labels.remove(&(abs.id, d as u8));
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
/// number of its own to show in a reference).
|
||||
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
|
||||
}
|
||||
|
||||
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
|
||||
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
|
||||
/// `None` for a bullet or "none" level, or a level the list doesn't define.
|
||||
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
|
||||
return last.label.clone();
|
||||
}
|
||||
// A deeper item can count a level implicitly without rendering a paragraph there.
|
||||
let def = numbering.level(num, level.min(8))?;
|
||||
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
|
||||
return None;
|
||||
}
|
||||
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
|
||||
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
|
||||
Some(render_label(numbering, num, def, &vals, &started))
|
||||
}
|
||||
|
||||
/// Levels shown by the last rendered label, or the current definition for an implicit level.
|
||||
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
|
||||
if let Some(abs) = numbering.abstract_of(num)
|
||||
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
|
||||
{
|
||||
return last.levels.clone();
|
||||
}
|
||||
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
|
||||
pub fn template_levels(text: &str) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
if !out.contains(&k) {
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
|
||||
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
let mut label = String::new();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
{
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
@@ -396,6 +481,45 @@ const MAX_LABEL_BYTES: usize = 256;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
|
||||
#[test]
|
||||
fn reference_labels_are_bounded_and_do_not_advance_counters() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
let before = c.values(&n, id);
|
||||
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
|
||||
assert_eq!(c.values(&n, id), before);
|
||||
assert!(label.len() <= 512);
|
||||
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
|
||||
assert!(c.started(&n, id).unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
|
||||
let mut numbering = Numbering::default();
|
||||
let first = numbering.add_list(ListKind::Numbered);
|
||||
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
|
||||
let mut second = numbering.num(first).unwrap().clone();
|
||||
second.id = first + 1;
|
||||
second.level_overrides = vec![
|
||||
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
|
||||
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
|
||||
];
|
||||
let second_id = second.id;
|
||||
numbering.nums.push(second);
|
||||
let mut counters = Counters::default();
|
||||
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
|
||||
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
|
||||
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
|
||||
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
|
||||
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
@@ -426,8 +550,9 @@ mod tests {
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "1.02");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "2.01");
|
||||
// Levels it doesn't define still come from the abstract list.
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1.");
|
||||
// Levels it doesn't define still come from the abstract list; level 0 took its first number
|
||||
// with the first level-1 item, so this is its second.
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "2.");
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
@@ -476,6 +601,40 @@ mod tests {
|
||||
assert_eq!(l(2), "(1)", "level 1 restarts level 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeper_item_first_counts_the_levels_above_it() {
|
||||
// Measured in Word: a level above that hasn't counted yet takes its first number at the
|
||||
// first deeper item, whether or not that item's text shows it.
|
||||
let labels_with = |starts: [u32; 3], texts: [&str; 3], overrides: &[(u8, u32)], seq: &[u8]| {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Legal);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
for (l, (s, t)) in a.levels.iter_mut().zip(starts.into_iter().zip(texts)) {
|
||||
l.start = s;
|
||||
l.text = t.into();
|
||||
}
|
||||
}
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = overrides.to_vec();
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
seq.iter().map(|lv| c.next_label(&n, id, *lv).unwrap().0).collect::<Vec<_>>()
|
||||
};
|
||||
let labels = |starts: [u32; 3], texts: [&str; 3], seq: &[u8]| labels_with(starts, texts, &[], seq);
|
||||
let std = ["%1.", "%1.%2.", "%1.%2.%3."];
|
||||
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 0]), ["1.1.", "1.2.", "2.", "3."]);
|
||||
assert_eq!(labels([10, 1, 1], std, &[1, 1, 1, 1, 0, 0]), ["10.1.", "10.2.", "10.3.", "10.4.", "11.", "12."]);
|
||||
assert_eq!(labels([1, 1, 1], ["%1.", "(%2)", "%1.%2.%3."], &[1, 1, 0, 0]), ["(1)", "(2)", "2.", "3."]);
|
||||
assert_eq!(labels([1, 1, 1], std, &[2, 1, 0, 1]), ["1.1.1.", "1.2.", "2.", "2.1."]);
|
||||
assert_eq!(labels([1, 1, 1], ["%1.", "%1.%2.", "(%3)"], &[2, 1, 0]), ["(1)", "1.2.", "2."]);
|
||||
assert_eq!(labels([1, 1, 1], std, &[0, 2, 1, 0]), ["1.", "1.1.1.", "1.2.", "2."]);
|
||||
// The num's own start for the level above counts in its place, as the level's start does.
|
||||
assert_eq!(labels_with([1, 1, 1], std, &[(0, 10)], &[1, 1, 0]), ["10.1.", "10.2.", "11."]);
|
||||
assert_eq!(labels_with([1, 1, 1], std, &[(0, 5)], &[1, 0, 1]), ["5.1.", "6.", "6.1."]);
|
||||
// A level-1 item after the first deeper ones restarts level 2 below it, as usual.
|
||||
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 1, 1, 0, 2]), ["1.1.", "1.2.", "2.", "2.1.", "2.2.", "3.", "3.1.1."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_level_starting_at_zero_counts_up() {
|
||||
let mut n = Numbering::default();
|
||||
|
||||
@@ -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<usize> {
|
||||
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<Item = (usize, &InlineObject)> + '_ {
|
||||
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.
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<f32>,
|
||||
/// 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<EmphasisMark>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
||||
@@ -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<TextAlignment> {
|
||||
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() {
|
||||
@@ -236,6 +305,9 @@ pub struct TextFrame {
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub anchor_lock: Option<bool>,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
@@ -290,6 +362,9 @@ pub struct ParaProps {
|
||||
/// outside ones, so they swap sides on even (left-hand) pages.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mirror_indents: Option<bool>,
|
||||
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub adjust_right_indent: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_next: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -363,6 +438,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<bool>,
|
||||
/// `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<TextAlignment>,
|
||||
}
|
||||
|
||||
impl ParaProps {
|
||||
@@ -374,7 +453,7 @@ impl ParaProps {
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
@@ -383,7 +462,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);
|
||||
|
||||
@@ -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<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after: Option<f32>,
|
||||
}
|
||||
|
||||
/// 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<Box<PropChange<CellProps>>>,
|
||||
/// 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<Box<CellPicture>>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -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),
|
||||
@@ -149,6 +153,10 @@ impl ResolvedChar {
|
||||
}
|
||||
|
||||
/// Concrete paragraph formatting.
|
||||
fn default_adjust_right_indent() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResolvedPara {
|
||||
@@ -169,6 +177,9 @@ pub struct ResolvedPara {
|
||||
pub contextual_spacing: bool,
|
||||
/// Left/right indents are inside/outside: they swap on even pages.
|
||||
pub mirror_indents: bool,
|
||||
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
|
||||
#[serde(default = "default_adjust_right_indent")]
|
||||
pub adjust_right_indent: bool,
|
||||
pub keep_next: bool,
|
||||
pub keep_lines: bool,
|
||||
pub page_break_before: bool,
|
||||
@@ -178,6 +189,10 @@ pub struct ResolvedPara {
|
||||
pub tabs: Vec<TabStop>,
|
||||
pub shading: Option<Rgb>,
|
||||
pub borders: Option<Borders>,
|
||||
/// 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`).
|
||||
@@ -256,6 +271,7 @@ impl ResolvedPara {
|
||||
},
|
||||
contextual_spacing: p.contextual_spacing.unwrap_or(false),
|
||||
mirror_indents: p.mirror_indents.unwrap_or(false),
|
||||
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
|
||||
keep_next: p.keep_next.unwrap_or(false),
|
||||
keep_lines: p.keep_lines.unwrap_or(false),
|
||||
page_break_before: p.page_break_before.unwrap_or(false),
|
||||
@@ -265,6 +281,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 +343,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 +439,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<T: Serialize + serde::de::DeserializeOwned + Default>(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<String, serde_json::Value> = 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 +586,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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,40 @@ pub enum NumFormat {
|
||||
DollarText,
|
||||
}
|
||||
|
||||
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChapterSep {
|
||||
Hyphen,
|
||||
Period,
|
||||
Colon,
|
||||
EmDash,
|
||||
EnDash,
|
||||
}
|
||||
|
||||
impl ChapterSep {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"hyphen" => Some(Self::Hyphen),
|
||||
"period" => Some(Self::Period),
|
||||
"colon" => Some(Self::Colon),
|
||||
"emDash" => Some(Self::EmDash),
|
||||
"enDash" => Some(Self::EnDash),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hyphen => "hyphen",
|
||||
Self::Period => "period",
|
||||
Self::Colon => "colon",
|
||||
Self::EmDash => "emDash",
|
||||
Self::EnDash => "enDash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NumFormat {
|
||||
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
|
||||
/// binary .doc number format codes up to 0x27).
|
||||
@@ -733,6 +767,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,
|
||||
@@ -742,6 +783,9 @@ pub struct SectionProps {
|
||||
pub title_page: bool,
|
||||
pub page_num_start: Option<u32>,
|
||||
pub page_num_format: NumFormat,
|
||||
/// Chapter heading level and separator in `w:pgNumType`.
|
||||
pub chapter_style: Option<u8>,
|
||||
pub chapter_sep: Option<ChapterSep>,
|
||||
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
|
||||
/// format), so saving writes it back rather than dropping it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -772,6 +816,18 @@ pub struct SectionProps {
|
||||
pub footnote_pr: NoteProps,
|
||||
#[serde(skip_serializing_if = "NoteProps::is_empty")]
|
||||
pub endnote_pr: NoteProps,
|
||||
/// First-page and other-page printer trays (`w:paperSrc`).
|
||||
pub paper_src_first: Option<u16>,
|
||||
pub paper_src_other: Option<u16>,
|
||||
/// Section-specific suppression of endnotes (`w:noEndnote`).
|
||||
pub no_endnote: Option<bool>,
|
||||
/// Independent footnote column count (`w15:footnoteColumns`).
|
||||
pub footnote_columns: Option<u32>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
impl Default for SectionProps {
|
||||
@@ -788,6 +844,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(),
|
||||
@@ -795,6 +853,8 @@ impl Default for SectionProps {
|
||||
title_page: false,
|
||||
page_num_start: None,
|
||||
page_num_format: NumFormat::Decimal,
|
||||
chapter_style: None,
|
||||
chapter_sep: None,
|
||||
page_num_set: false,
|
||||
line_numbers: None,
|
||||
valign: PageVAlign::Top,
|
||||
@@ -807,10 +867,19 @@ impl Default for SectionProps {
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
endnote_pr: NoteProps::default(),
|
||||
paper_src_first: None,
|
||||
paper_src_other: None,
|
||||
no_endnote: None,
|
||||
footnote_columns: None,
|
||||
balance_columns: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &f32) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
|
||||
!d.is_turned()
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<OdfLayout>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Rgb>,
|
||||
}
|
||||
|
||||
impl SmartArtItem {
|
||||
pub fn new(text: impl Into<String>, 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<usize> {
|
||||
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();
|
||||
|
||||
@@ -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")]
|
||||
@@ -932,6 +966,31 @@ fn table_styles() -> Vec<Style> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::resolve::ResolvedPara;
|
||||
|
||||
#[test]
|
||||
fn adjust_right_indent_inherits_and_can_be_overridden() {
|
||||
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
|
||||
assert!(resolved.adjust_right_indent);
|
||||
|
||||
let mut older_json = serde_json::to_value(&resolved).unwrap();
|
||||
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
|
||||
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
|
||||
|
||||
let mut s = StyleSheet::empty();
|
||||
s.styles.push(Style {
|
||||
id: "Normal".into(),
|
||||
name: "Normal".into(),
|
||||
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
|
||||
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
|
||||
|
||||
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
|
||||
assert_eq!(json["adjustRightIndent"], false);
|
||||
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_present() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+48
-9
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
@@ -18,6 +19,25 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
|
||||
/// with spaces leave the paragraph breaks out, characters without spaces leave every
|
||||
/// whitespace character out.
|
||||
#[test]
|
||||
fn character_counts() {
|
||||
assert_eq!(count_chars(""), 0);
|
||||
assert_eq!(count_chars("Hello"), 5);
|
||||
// Paragraph breaks join the paragraphs but aren't characters.
|
||||
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
|
||||
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
|
||||
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
|
||||
assert_eq!(count_chars_no_spaces("Hello world"), 10);
|
||||
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
|
||||
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
|
||||
// Accented letters and emoji count as one character each, as they are.
|
||||
assert_eq!(count_chars("café 🚀"), 6);
|
||||
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
@@ -35,8 +55,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 +80,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 +101,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 +207,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 +249,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();
|
||||
@@ -448,6 +468,25 @@ fn box_para(story: u32, id: u32) -> Blocks {
|
||||
vec![para_block(p)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet);
|
||||
let mut theirs = Document::from_text("theirs");
|
||||
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 });
|
||||
theirs.body = nested_tables(20, vec![para_block(p)]);
|
||||
let mut nums = Vec::new();
|
||||
for b in &ours.adopt(theirs).blocks {
|
||||
edit::each_para(b, 0, &mut |p| nums.extend(p.props.numbering.map(|n| n.num)));
|
||||
}
|
||||
assert_eq!(nums.len(), 1);
|
||||
assert_ne!(nums[0], our_list);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
|
||||
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
|
||||
|
||||
@@ -102,18 +102,6 @@ fn dense(n: usize) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
@@ -128,18 +116,13 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
|
||||
// the references and runs when they are walked together, about 64x when each run rescans the
|
||||
// references.
|
||||
let s1 = crate::work::count(|| convert(&small));
|
||||
let s8 = crate::work::count(|| convert(&big));
|
||||
let ratio = s8 as f64 / s1.max(1) as f64;
|
||||
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
|
||||
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
|
||||
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ fn is_cjk_word(c: char) -> bool {
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
|
||||
/// paragraph breaks aren't characters (#2797).
|
||||
pub fn count_chars(s: &str) -> usize {
|
||||
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
|
||||
}
|
||||
|
||||
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
|
||||
/// ("Characters (no spaces)"; #2797).
|
||||
pub fn count_chars_no_spaces(s: &str) -> usize {
|
||||
s.chars().filter(|c| !c.is_whitespace()).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
@@ -51,6 +63,7 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
|
||||
return;
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
crate::work::steps(1);
|
||||
prefix.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(_) => out.push(Path(prefix.clone())),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! A count of the characters a paragraph's object lookups walk, the blocks
|
||||
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
|
||||
//! descends, 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<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
|
||||
/// paragraph's text themselves (Markdown and HTML comment export, for one).
|
||||
#[inline(always)]
|
||||
pub 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)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it
|
||||
//! (#3660). Every reader of `.docx`, `.odt` and spreadsheet packages calls [`check`] first.
|
||||
//!
|
||||
//! `zip::ZipArchive::new` reserves room for, and parses, as many central directory headers as the
|
||||
//! end record declares before its caller can look at `len()`. A ZIP64 end record can declare
|
||||
//! millions of entries for a file of a few hundred megabytes (only 46 bytes each have to fit), and
|
||||
//! each parsed entry costs several times that in memory. Here the end records are found the way
|
||||
//! `zip` finds them (APPNOTE.TXT §4.3.14 to §4.3.16), and a package declaring more than the
|
||||
//! reader's limit is refused before `zip` parses anything. Anything else that is wrong is left for
|
||||
//! `zip` to report.
|
||||
|
||||
const EOCD: &[u8; 4] = b"PK\x05\x06";
|
||||
const EOCD64_LOCATOR: &[u8; 4] = b"PK\x06\x07";
|
||||
const EOCD64: &[u8; 4] = b"PK\x06\x06";
|
||||
const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02";
|
||||
|
||||
/// End records looked at, at most. A package has one; a few more can turn up inside stored data.
|
||||
const MAX_END_RECORDS: usize = 16;
|
||||
|
||||
/// Refuse `bytes` when the end record `zip` would use declares more than `max_entries` entries.
|
||||
///
|
||||
/// `zip` searches the file backwards for an end of central directory record. A ZIP64 one points
|
||||
/// through its locator at the ZIP64 end record, which `zip` looks for from the locator's offset
|
||||
/// on: every such record there is checked, and as `zip` may fall back to an earlier end record
|
||||
/// when one doesn't parse, the search goes on. A plain one is used when it declares no entries, or
|
||||
/// a central directory header lies between its directory offset and itself: it is the last one
|
||||
/// looked at.
|
||||
pub fn check(bytes: &[u8], max_entries: u64) -> Result<(), String> {
|
||||
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
let u64_at = |at: usize| bytes.get(at..at.checked_add(8)?).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes);
|
||||
let refuse = |n: u64| -> Result<(), String> {
|
||||
if n > max_entries { Err(format!("the package declares {n} entries, more than {max_entries}")) } else { Ok(()) }
|
||||
};
|
||||
let mut end = bytes.len();
|
||||
let mut seen = 0;
|
||||
while let Some(at) = bytes.get(..end).and_then(|b| b.windows(4).rposition(|w| w == EOCD)) {
|
||||
end = at;
|
||||
seen += 1;
|
||||
if seen > MAX_END_RECORDS {
|
||||
return Err(format!("more than {MAX_END_RECORDS} end of central directory records"));
|
||||
}
|
||||
// `zip` skips a record whose comment runs past the end of the file.
|
||||
let Some(comment) = u16_at(at + 20) else { continue };
|
||||
if at + 22 + usize::from(comment) > bytes.len() {
|
||||
continue;
|
||||
}
|
||||
let (Some(on_disk), Some(total), Some(cd_offset)) = (u16_at(at + 8), u16_at(at + 10), u32_at(at + 16)) else { continue };
|
||||
let zip64 = total == u16::MAX || cd_offset == u32::MAX;
|
||||
if zip64
|
||||
&& let Some(locator) = at.checked_sub(20)
|
||||
&& bytes.get(locator..locator + 4) == Some(EOCD64_LOCATOR.as_slice())
|
||||
{
|
||||
let (Some(eocd64), Some(disks)) = (u64_at(locator + 8), u32_at(locator + 16)) else { continue };
|
||||
let Some(from) = usize::try_from(eocd64).ok().filter(|&f| f < locator) else { continue };
|
||||
if disks > 1 {
|
||||
continue;
|
||||
}
|
||||
let records = bytes.get(from..locator).unwrap_or_default().windows(4).enumerate().filter(|(_, w)| *w == EOCD64);
|
||||
for (i, _) in records {
|
||||
let p = from + i;
|
||||
refuse(u64_at(p + 24).unwrap_or(0).max(u64_at(p + 32).unwrap_or(0)))?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if total == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let cd = cd_offset as usize;
|
||||
if cd < at && bytes.get(cd..at).is_some_and(|b| b.windows(4).any(|w| w == CENTRAL_HEADER)) {
|
||||
return refuse(u64::from(on_disk.max(total)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A package of one empty stored file, `a`, whose central directory lists it `entries` times (at
|
||||
/// most as many as fit in 64 MiB) under a ZIP64 end record that declares them all: `zip` 3.0.0
|
||||
/// parses and keeps every one of them before its caller can count them. Regression input for the
|
||||
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
|
||||
/// which they turn on as a dev-dependency) the readers', never into the app.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn zip64_entry_count_sample(entries: u64) -> Vec<u8> {
|
||||
// Local file header (APPNOTE.TXT §4.3.7): version 10, stored, no data, name "a".
|
||||
let mut f = b"PK\x03\x04".to_vec();
|
||||
f.extend_from_slice(&[10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, method, time, date
|
||||
f.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
f.extend_from_slice(&[1, 0, 0, 0]); // name and extra lengths
|
||||
f.push(b'a');
|
||||
let cd_at = f.len() as u64;
|
||||
// Its central header (§4.3.12), 47 bytes.
|
||||
let mut header = CENTRAL_HEADER.to_vec();
|
||||
header.extend_from_slice(&[10, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // versions, flags, method, time, date
|
||||
header.extend_from_slice(&[0; 12]); // crc, sizes
|
||||
header.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // name, extra, comment, disk, attributes
|
||||
header.extend_from_slice(&[0; 4]); // local header offset
|
||||
header.push(b'a');
|
||||
let count = entries.min((64 << 20) / header.len() as u64);
|
||||
for _ in 0..count {
|
||||
f.extend_from_slice(&header);
|
||||
}
|
||||
let cd_len = f.len() as u64 - cd_at;
|
||||
let eocd64_at = f.len() as u64;
|
||||
// ZIP64 end of central directory record (§4.3.14), 56 bytes.
|
||||
f.extend_from_slice(EOCD64);
|
||||
f.extend_from_slice(&44u64.to_le_bytes()); // size of the rest of the record
|
||||
f.extend_from_slice(&[45, 0, 45, 0]); // versions
|
||||
f.extend_from_slice(&[0; 8]); // this disk, disk with the central directory
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries on this disk
|
||||
f.extend_from_slice(&entries.to_le_bytes()); // entries
|
||||
f.extend_from_slice(&cd_len.to_le_bytes());
|
||||
f.extend_from_slice(&cd_at.to_le_bytes());
|
||||
// Its locator (§4.3.15), 20 bytes.
|
||||
f.extend_from_slice(EOCD64_LOCATOR);
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&eocd64_at.to_le_bytes());
|
||||
f.extend_from_slice(&1u32.to_le_bytes());
|
||||
// End of central directory record (§4.3.16), 22 bytes, each count and offset "see ZIP64".
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0; 4]);
|
||||
f.extend_from_slice(&[0xFF; 12]);
|
||||
f.extend_from_slice(&[0; 2]);
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CENTRAL_HEADER, EOCD, check, zip64_entry_count_sample};
|
||||
|
||||
/// A plain package's tail: one central header at 0, then an end record declaring `entries`.
|
||||
fn plain(entries: u16, comment: &[u8]) -> Vec<u8> {
|
||||
let mut f = CENTRAL_HEADER.to_vec();
|
||||
f.resize(46, 0);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&entries.to_le_bytes());
|
||||
f.extend_from_slice(&46u32.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&(comment.len() as u16).to_le_bytes());
|
||||
f.extend_from_slice(comment);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip64_end_record_declaring_too_many_entries_is_refused() {
|
||||
// #3660: `zip` would reserve and parse them all before the reader's cap.
|
||||
let sample = zip64_entry_count_sample(20_001);
|
||||
assert!(check(&sample, 20_000).is_err_and(|e| e.contains("20001 entries")));
|
||||
assert!(check(&zip64_entry_count_sample(5), 20_000).is_ok());
|
||||
// Junk before the package (a self-extracting stub) doesn't hide it.
|
||||
let mut prefixed = vec![0x55u8; 4096];
|
||||
prefixed.extend_from_slice(&sample);
|
||||
assert!(check(&prefixed, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_end_record_is_checked_as_zip_reads_it() {
|
||||
assert!(check(&plain(3, b""), 20_000).is_ok());
|
||||
assert!(check(&plain(30_000, b"a comment"), 20_000).is_err_and(|e| e.contains("30000 entries")));
|
||||
// A record whose comment runs past the end is not the one `zip` uses.
|
||||
let mut cut = plain(30_000, b"");
|
||||
cut.truncate(cut.len() - 2);
|
||||
cut.extend_from_slice(&500u16.to_le_bytes());
|
||||
assert!(check(&cut, 20_000).is_ok());
|
||||
assert!(check(b"not a package", 20_000).is_ok());
|
||||
assert!(check(b"", 20_000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fake_end_record_after_the_real_one_does_not_hide_it() {
|
||||
// `zip` skips a last record with no central header before it and uses the one before.
|
||||
let mut f = zip64_entry_count_sample(1_000_000);
|
||||
f.extend_from_slice(EOCD);
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&7u16.to_le_bytes());
|
||||
f.extend_from_slice(&0u32.to_le_bytes());
|
||||
f.extend_from_slice(&((f.len() - 12) as u32).to_le_bytes());
|
||||
f.extend_from_slice(&0u16.to_le_bytes());
|
||||
assert!(check(&f, 20_000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_end_records_are_refused() {
|
||||
// Each declares one entry and has no central header before it, so `zip` would try them all.
|
||||
let record = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
let f: Vec<u8> = (0..20).flat_map(|_| EOCD.iter().copied().chain(record)).collect();
|
||||
assert!(check(&f, 20_000).is_err_and(|e| e.contains("end of central directory records")));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+29
-11
@@ -20,7 +20,9 @@ const C_ICO: u16 = 0x2A42; // palette colour
|
||||
const C_CR: u16 = 0x6870; // explicit COLORREF
|
||||
const C_HPS: u16 = 0x4A43; // half-points
|
||||
const C_HPS_POS: u16 = 0x4845; // super/subscript by half-points
|
||||
const C_FTC0: u16 = 0x4A4F; // font index
|
||||
const C_FTC0: u16 = 0x4A4F; // font index (ASCII)
|
||||
const C_FTC1: u16 = 0x4A50; // East Asian font index (sprmCRgFtc1)
|
||||
const C_FTC_BI: u16 = 0x4A5E; // complex-script font index (sprmCFtcBi)
|
||||
const C_HIGHLIGHT: u16 = 0x2A0C;
|
||||
const C_ISTD: u16 = 0x4A30; // character style
|
||||
|
||||
@@ -107,12 +109,13 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
|
||||
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,
|
||||
@@ -123,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<Border> {
|
||||
pub(crate) fn brc80(op: &[u8]) -> Option<Border> {
|
||||
let [dpt, ty, ico_index, tail, ..] = op else { return None };
|
||||
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
|
||||
return Some(Border::default());
|
||||
@@ -254,6 +257,16 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts:
|
||||
p.font = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_FTC1 => {
|
||||
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
|
||||
p.font_ea = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_FTC_BI => {
|
||||
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
|
||||
p.font_cs = Some(name.clone());
|
||||
}
|
||||
}
|
||||
C_HIGHLIGHT => {
|
||||
if let Some(h) = HL.get(prl.operand.first().copied().unwrap_or(0) as usize) {
|
||||
p.highlight = Some(*h);
|
||||
@@ -417,10 +430,15 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
|
||||
|
||||
/// Build the model stylesheet: resolve each raw style's chain (base → this) and the ids we
|
||||
/// reference from paragraphs.
|
||||
pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String]) -> StyleSheet {
|
||||
pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String], standard: crate::stsh::StandardFonts) -> StyleSheet {
|
||||
let mut sheet = StyleSheet::empty();
|
||||
// Word's base character properties: font index 0 of the table, 10 pt ([MS-DOC] sprmCHps).
|
||||
sheet.default_chr.font = fonts.first().cloned();
|
||||
// Word's base character properties: the stylesheet's standard fonts (Aptos in a Word 365
|
||||
// file, whose font table still starts with Times New Roman, #5066), else font index 0 of the
|
||||
// table; 10 pt ([MS-DOC] sprmCHps).
|
||||
let font = |i: Option<u16>| i.and_then(|i| fonts.get(usize::from(i))).cloned();
|
||||
sheet.default_chr.font = font(standard.ascii).or_else(|| fonts.first().cloned());
|
||||
sheet.default_chr.font_ea = font(standard.east_asian);
|
||||
sheet.default_chr.font_cs = font(standard.bidi);
|
||||
if sheet.default_chr.size.is_none() {
|
||||
sheet.default_chr.size = Some(10.0);
|
||||
}
|
||||
|
||||
+120
-106
@@ -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;
|
||||
@@ -49,6 +51,14 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// Most paragraph and cell marks across every story walk of one document. The character
|
||||
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
|
||||
/// file of nothing but marks would grow memory about a thousand times faster than its size.
|
||||
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
|
||||
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
|
||||
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
|
||||
/// rows, each a cell mark plus a row mark) is about 920,000.
|
||||
const MAX_PARAGRAPHS: usize = 2_000_000;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
@@ -193,6 +203,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
read_limited(bytes, password, MAX_PARAGRAPHS)
|
||||
}
|
||||
|
||||
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
|
||||
/// file of [`MAX_PARAGRAPHS`] marks.
|
||||
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
|
||||
let mut comp = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
@@ -233,6 +249,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
Some((f, l)) => stsh::parse(&table, f, l)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let standard_fonts = fib.pair(fib::pair::STSHF).map(|(f, l)| stsh::standard_fonts(&table, f, l)).unwrap_or_default();
|
||||
let chpx_bins = match fib.pair(fib::pair::PLCF_BTE_CHPX) {
|
||||
Some((f, l)) => Bins::parse(&table, f, l)?,
|
||||
None => Bins::parse(&[], 0, 0)?,
|
||||
@@ -247,9 +264,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.core = core;
|
||||
}
|
||||
doc.body.clear();
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
|
||||
doc.numbering = list::parse(&table, &fib, &fonts);
|
||||
let sheet: StyleSheet = doc.styles.clone();
|
||||
doc.styles = Arc::new(fmt::stylesheet(&raw_styles, &fonts, standard_fonts));
|
||||
doc.numbering = Arc::new(list::parse(&table, &fib, &fonts));
|
||||
let sheet: StyleSheet = StyleSheet::clone(&doc.styles);
|
||||
let sects = sections::parse(&word, &table, &fib);
|
||||
let stories = sections::header_stories(&table, &fib);
|
||||
// Subdocument layout in the CP space: main document, footnotes, headers, comments,
|
||||
@@ -267,6 +284,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -282,6 +300,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
paragraphs: ¶graphs,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
@@ -301,7 +320,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
@@ -326,7 +345,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -339,7 +358,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
|
||||
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Footnote, blocks);
|
||||
note_links.push((cp, NoteKind::Footnote, id));
|
||||
@@ -347,7 +366,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
}
|
||||
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
|
||||
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Endnote, blocks);
|
||||
note_links.push((cp, NoteKind::Endnote, id));
|
||||
@@ -363,6 +382,11 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
// Sections: each section's properties attach to the paragraph that ends it (its story of
|
||||
// header/footer parts included); the section reaching the end of the text is the final one.
|
||||
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
|
||||
// The first paragraph ending at each CP, found once rather than by a scan per section.
|
||||
let mut ending: std::collections::HashMap<u32, usize> = 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();
|
||||
@@ -373,11 +397,8 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if is_final {
|
||||
continue; // handled below through `last`
|
||||
}
|
||||
for p in paras.iter_mut() {
|
||||
if p.end_cp == sec.end_cp {
|
||||
p.para.section = Some(Box::new(props));
|
||||
break;
|
||||
}
|
||||
if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) {
|
||||
p.para.section = Some(Box::new(props));
|
||||
}
|
||||
}
|
||||
if let Some(mut l) = last {
|
||||
@@ -388,6 +409,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.last_section = l;
|
||||
}
|
||||
|
||||
if paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
@@ -395,6 +419,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
|
||||
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
|
||||
}
|
||||
|
||||
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
|
||||
/// its partial list is assembled, so a flood does not build blocks it will throw away.
|
||||
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
@@ -428,10 +466,35 @@ struct WalkCtx<'a> {
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
|
||||
paragraphs: &'a ParaBudget,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
|
||||
/// the end of them (the read then fails).
|
||||
struct ParaBudget {
|
||||
left: std::cell::Cell<usize>,
|
||||
exceeded: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl ParaBudget {
|
||||
/// Spends one mark; false (and the overrun noted) when none are left.
|
||||
fn spend(&self) -> bool {
|
||||
match self.left.get().checked_sub(1) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.exceeded.set(true);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
#[derive(Default)]
|
||||
struct ParaBuild {
|
||||
@@ -522,6 +585,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
|
||||
match c {
|
||||
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
|
||||
if !ctx.paragraphs.spend() {
|
||||
break;
|
||||
}
|
||||
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
|
||||
// querying at the mark's own FC finds that paragraph's properties.
|
||||
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
@@ -531,6 +597,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
}
|
||||
let mut row = table::decode(papx);
|
||||
row.cell_mark = c == '\u{7}';
|
||||
// Only table marks keep their table context; a plain paragraph would carry
|
||||
// a `RowInfo` (about 300 bytes) it never uses.
|
||||
let row = row.is_table().then(|| Box::new(row));
|
||||
let mut ilvl = 0u8;
|
||||
let mut ilfo = 0i32;
|
||||
for prl in sprm::iter(papx) {
|
||||
@@ -584,7 +653,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
&& f.depth == 1
|
||||
&& f.sep.is_none()
|
||||
{
|
||||
f.sep = Some(Vec::new());
|
||||
f.sep = Some(FieldResult::default());
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
@@ -618,6 +687,7 @@ fn walk<'a>(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,
|
||||
});
|
||||
}
|
||||
@@ -663,7 +733,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
out.push(ParaOut {
|
||||
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
|
||||
end_cp: cp_end,
|
||||
row: table::RowInfo::default(),
|
||||
row: None,
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -674,7 +744,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
struct FieldBuild {
|
||||
depth: u32,
|
||||
instr: String,
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
sep: Option<FieldResult>,
|
||||
}
|
||||
|
||||
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
|
||||
/// only when the formatting changes, so a result of millions of equally formatted characters
|
||||
/// costs one `CharProps`, not one per character (365 bytes each).
|
||||
#[derive(Default)]
|
||||
struct FieldResult {
|
||||
text: String,
|
||||
/// (byte length in `text`, formatting) in order; adjacent runs differ.
|
||||
runs: Vec<(usize, CharProps)>,
|
||||
}
|
||||
|
||||
impl FieldResult {
|
||||
fn push(&mut self, c: char, props: CharProps) {
|
||||
let len = c.len_utf8();
|
||||
match self.runs.last_mut() {
|
||||
Some((n, last)) if *last == props => *n += len,
|
||||
_ => self.runs.push((len, props)),
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
@@ -699,103 +790,26 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let result = f.sep.unwrap_or_default();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
let cost = target.len() as u64;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
let mut chars = result.text.chars();
|
||||
for (len, props) in result.runs {
|
||||
let mut linked = props;
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
let mut left = len;
|
||||
let Some(first) = chars.next() else { break };
|
||||
pb.push(first, linked);
|
||||
left = left.saturating_sub(first.len_utf8());
|
||||
while left > 0 {
|
||||
let Some(c) = chars.next() else { break };
|
||||
pb.extend(c);
|
||||
left = left.saturating_sub(c.len_utf8());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
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<String> {
|
||||
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<String> = None;
|
||||
let mut local: Option<String> = 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,
|
||||
}
|
||||
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
@@ -879,7 +893,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
|
||||
Some(f) => match f.sep.as_mut() {
|
||||
// Inside a field, characters are recorded for the field object instead of shown;
|
||||
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
|
||||
Some(result) => result.push((c, props)),
|
||||
Some(result) => result.push(c, props),
|
||||
None => f.instr.push(c),
|
||||
},
|
||||
None => pb.push(c, props),
|
||||
|
||||
@@ -20,6 +20,41 @@ pub(crate) struct RawStyle {
|
||||
pub(crate) chpx: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The stylesheet's standard fonts, the ones text takes when neither its style nor its own
|
||||
/// formatting names a font: font-table indexes of the ASCII, East Asian and other
|
||||
/// (non-East-Asian, non-ASCII) fonts (`Stshif.rgftcStandardChpStsh`, [MS-DOC] §2.9.273) and the
|
||||
/// complex-script font (`Stshi.ftcBi`, §2.9.272). `None` where the header is too short for one.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StandardFonts {
|
||||
pub(crate) ascii: Option<u16>,
|
||||
pub(crate) east_asian: Option<u16>,
|
||||
pub(crate) other: Option<u16>,
|
||||
pub(crate) bidi: Option<u16>,
|
||||
}
|
||||
|
||||
/// `StshiLsd.cbLSD`: the size of a latent style's `LSD` ([MS-DOC] `StshiLsd`).
|
||||
const LSD_SIZE: u16 = 4;
|
||||
|
||||
/// The [`StandardFonts`] in the header (`Stshi`) of the `STSH` at `table[fc..fc+lcb]`: Word 365
|
||||
/// writes Aptos there while the font table's first entry is still Times New Roman (#5066).
|
||||
pub(crate) fn standard_fonts(table: &[u8], fc: u32, lcb: u32) -> StandardFonts {
|
||||
let Some(stsh) = (fc as usize).checked_add(lcb as usize).and_then(|end| table.get(fc as usize..end)) else { return StandardFonts::default() };
|
||||
let cb_stshi = stsh.get(0..2).map_or(0, |b| u16::from_le_bytes([b[0], b[1]]) as usize);
|
||||
let stshi = stsh.get(2..).map_or(&[][..], |s| s.get(..cb_stshi.min(s.len())).unwrap_or(&[]));
|
||||
// Stshif: cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
|
||||
// nVerBuiltInNamesWhenSaved (2 bytes each), then rgftcStandardChpStsh (3 × 2); ftcBi follows.
|
||||
let at = |o: usize| stshi.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
// ftcBi is optional, and the latent style data (`StshiLsd`, whose `cbLSD` MUST be 4) follows
|
||||
// it: a 4 right after it says it is there, a 4 in its place with more data after says it was
|
||||
// left out. A header that ends right after the field (as Word 97's) has it, a 4 included.
|
||||
let bidi = match (at(18), at(20)) {
|
||||
(b, Some(LSD_SIZE) | None) => b,
|
||||
(Some(LSD_SIZE), Some(_)) => None,
|
||||
(b, _) => b,
|
||||
};
|
||||
StandardFonts { ascii: at(12), east_asian: at(14), other: at(16), bidi }
|
||||
}
|
||||
|
||||
/// Parse the `STSH` at `table[fc..fc+lcb]`.
|
||||
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Vec<RawStyle>, DocbinError> {
|
||||
let fc = fc as usize;
|
||||
|
||||
+38
-37
@@ -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};
|
||||
|
||||
@@ -83,6 +83,11 @@ pub(crate) struct RowInfo {
|
||||
|
||||
/// Read the table context out of a paragraph mark's PAPX grpprl.
|
||||
impl RowInfo {
|
||||
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
|
||||
pub(crate) fn is_table(&self) -> bool {
|
||||
self.in_table || self.ttp || self.cell_mark
|
||||
}
|
||||
|
||||
/// The row height from `sprmTDyaRowHeight`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn height(&self) -> Option<f32> {
|
||||
@@ -101,6 +106,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<f32> {
|
||||
@@ -108,6 +119,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<T>(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<i16> = None;
|
||||
@@ -144,8 +164,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 +180,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 +191,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 +199,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 +219,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 +230,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 +247,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,8 +268,8 @@ pub(crate) struct ParaOut {
|
||||
pub(crate) para: Paragraph,
|
||||
/// CP just past the terminating mark.
|
||||
pub(crate) end_cp: u32,
|
||||
/// Table context from the mark's PAPX (empty default = not in a table).
|
||||
pub(crate) row: RowInfo,
|
||||
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
|
||||
pub(crate) row: Option<Box<RowInfo>>,
|
||||
}
|
||||
|
||||
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
|
||||
@@ -351,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
|
||||
for p in paras {
|
||||
let info = p.row;
|
||||
let is_table = info.in_table || info.ttp || info.cell_mark;
|
||||
if !is_table {
|
||||
let Some(info) = p.row else {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
@@ -368,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
out.push(para_block(p.para));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !open {
|
||||
open = true;
|
||||
}
|
||||
@@ -440,32 +461,12 @@ fn def_table_edges(operand: &[u8]) -> Vec<f32> {
|
||||
}
|
||||
|
||||
/// 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<Borders> {
|
||||
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<Border> {
|
||||
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.
|
||||
|
||||
+325
-12
@@ -116,6 +116,12 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
|
||||
|
||||
/// A document whose main text is the given pieces (extra subdocument text may follow).
|
||||
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
|
||||
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
|
||||
}
|
||||
|
||||
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
|
||||
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
|
||||
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
|
||||
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
|
||||
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
|
||||
let mut word = vec![0u8; TEXT_FC as usize];
|
||||
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
|
||||
clx.extend_from_slice(&plc);
|
||||
let mut table = vec![0u8; CLX_AT as usize];
|
||||
table.extend_from_slice(&clx);
|
||||
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
|
||||
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
|
||||
for (pair, bytes) in table_data {
|
||||
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
|
||||
table.extend_from_slice(bytes);
|
||||
}
|
||||
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
|
||||
let spec = FibSpec { ccp_text, pairs, ..spec };
|
||||
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
|
||||
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
|
||||
}
|
||||
@@ -247,6 +259,77 @@ fn empty_paragraphs_kept() {
|
||||
assert_eq!(body_texts(&doc), ["a", "", "b"]);
|
||||
}
|
||||
|
||||
/// A document of `n` copies of `mark` as one compressed piece.
|
||||
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
|
||||
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
|
||||
}
|
||||
|
||||
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
|
||||
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
|
||||
/// until the machine runs out. A document exactly at the cap still opens.
|
||||
#[test]
|
||||
fn a_flood_of_paragraph_marks_is_refused() {
|
||||
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 1000);
|
||||
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
|
||||
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
|
||||
#[test]
|
||||
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
|
||||
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
|
||||
let doc_of = |body: usize, note: usize| {
|
||||
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let mut refs = cps(&[0, 1]);
|
||||
refs.extend_from_slice(&[0, 0]); // the one reference's data
|
||||
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
|
||||
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
|
||||
text_doc_with(
|
||||
&[Piece { compressed: true, text: "\r".repeat(body) }],
|
||||
&[Piece { compressed: true, text: "\r".repeat(note) }],
|
||||
spec,
|
||||
&[(2, &refs), (3, &txt)],
|
||||
)
|
||||
};
|
||||
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
|
||||
assert_eq!(at_cap.body.len(), 600);
|
||||
assert_eq!(at_cap.parts.len(), 1);
|
||||
let over = super::read_limited(&doc_of(600, 401), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
let over = super::read_limited(&doc_of(601, 400), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
|
||||
#[test]
|
||||
fn a_flood_of_cell_marks_is_refused() {
|
||||
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
|
||||
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
|
||||
}
|
||||
|
||||
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
|
||||
/// every story of the document).
|
||||
#[test]
|
||||
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
|
||||
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
|
||||
assert!(budget.spend() && budget.spend());
|
||||
assert!(!budget.exceeded.get());
|
||||
assert!(!budget.spend());
|
||||
assert!(budget.exceeded.get());
|
||||
}
|
||||
|
||||
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
|
||||
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
|
||||
#[test]
|
||||
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
|
||||
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
|
||||
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
|
||||
let doc = read(&marks_doc('\r', 10)).expect("opens");
|
||||
assert_eq!(doc.body.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_gets_a_paragraph() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
|
||||
@@ -421,7 +504,17 @@ fn std_bytes_next(stk: u8, istd_base: u16, istd_next: u16, name: &str, papx: &[u
|
||||
|
||||
/// An STSH with the given styles.
|
||||
fn stsh_bytes(styles: &[Vec<u8>]) -> Vec<u8> {
|
||||
let stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
|
||||
stsh_bytes_with(styles, None)
|
||||
}
|
||||
|
||||
/// An STSH with the given styles; with `standard`, a full Stshif and ftcBi giving the standard
|
||||
/// fonts' indexes (ASCII, East Asian, other, complex script), as Word writes them.
|
||||
fn stsh_bytes_with(styles: &[Vec<u8>], standard: Option<[u16; 4]>) -> Vec<u8> {
|
||||
let mut stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
|
||||
if let Some(fonts) = standard {
|
||||
stshi.extend_from_slice(&[0u8; 8]); // flags, stiMaxWhenSaved, istdMaxFixedWhenSaved, nVerBuiltInNamesWhenSaved
|
||||
stshi.extend(fonts.iter().flat_map(|f| f.to_le_bytes()));
|
||||
}
|
||||
let mut v = (stshi.len() as u16).to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&stshi);
|
||||
for s in styles {
|
||||
@@ -532,6 +625,8 @@ struct RichExtra {
|
||||
prm: u16,
|
||||
alt_fonts: Vec<Option<&'static str>>,
|
||||
style_next: Vec<u16>,
|
||||
/// The stylesheet's standard fonts (ASCII, East Asian, other, complex script).
|
||||
standard_fonts: Option<[u16; 4]>,
|
||||
}
|
||||
|
||||
fn rich_doc(spec: &RichSpec) -> Vec<u8> {
|
||||
@@ -571,7 +666,7 @@ fn rich_doc_with(spec: &RichSpec, extra: &RichExtra) -> Vec<u8> {
|
||||
.enumerate()
|
||||
.map(|(i, s)| std_bytes_next(s.0, s.1, extra.style_next.get(i).copied().unwrap_or(0x0FFF), s.2, &s.3, &s.4))
|
||||
.collect();
|
||||
let stsh = stsh_bytes(&styles);
|
||||
let stsh = stsh_bytes_with(&styles, extra.standard_fonts);
|
||||
let fonts: Vec<(&str, Option<&str>)> = spec.fonts.iter().enumerate().map(|(i, n)| (*n, extra.alt_fonts.get(i).copied().flatten())).collect();
|
||||
let ffn = ffn_bytes_alt(&fonts);
|
||||
let clx_at = 0x40;
|
||||
@@ -654,6 +749,67 @@ fn font_and_size_from_style_and_direct() {
|
||||
assert!(p.runs.first().is_some_and(|r| r.props.font.as_deref() == Some("Arial")), "run font: {:?}", p.runs);
|
||||
}
|
||||
|
||||
/// #5066: the default font is the stylesheet's standard font (Aptos in a Word 365 file), not
|
||||
/// the font table's first entry (still Times New Roman there); the East Asian and complex-script
|
||||
/// standard fonts are the defaults for those scripts, and runs name theirs with sprmCRgFtc1 and
|
||||
/// sprmCFtcBi.
|
||||
#[test]
|
||||
fn the_default_font_is_the_stylesheets_standard_font() {
|
||||
let spec = |runs: Vec<(u32, Vec<u8>)>| RichSpec {
|
||||
text: "Hello\r",
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())]))],
|
||||
fonts: vec!["Times New Roman", "Symbol", "Arial", "Aptos", "MS Mincho", "Arial Unicode"],
|
||||
chpx_runs: runs,
|
||||
papx_entries: vec![(0, 0, Vec::new())],
|
||||
};
|
||||
let with = |standard: Option<[u16; 4]>, runs| {
|
||||
read(&rich_doc_with(&spec(runs), &RichExtra { standard_fonts: standard, ..Default::default() })).expect("opens")
|
||||
};
|
||||
let doc = with(Some([3, 4, 3, 2]), vec![(0, Vec::new())]);
|
||||
let d = &doc.styles.default_chr;
|
||||
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Aptos"), Some("MS Mincho"), Some("Arial")));
|
||||
let p = para0(&doc);
|
||||
let resolved = doc.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!(resolved.font, "Aptos", "Normal names no font, so the text takes the standard one");
|
||||
// A run's own East Asian and complex-script fonts.
|
||||
let doc = with(Some([3, 4, 3, 2]), vec![(0, grpprl(&[(0x4A50, &5u16.to_le_bytes()), (0x4A5E, &3u16.to_le_bytes())]))]);
|
||||
let run = ¶0(&doc).runs[0].props;
|
||||
assert_eq!((run.font_ea.as_deref(), run.font_cs.as_deref()), (Some("Arial Unicode"), Some("Aptos")));
|
||||
// Indexes past the font table, or a header too short to hold them, keep entry 0.
|
||||
for standard in [Some([99, 99, 99, 99]), None] {
|
||||
let doc = with(standard, vec![(0, Vec::new())]);
|
||||
let d = &doc.styles.default_chr;
|
||||
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Times New Roman"), None, None), "{standard:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #5066: `ftcBi` is optional in the stylesheet header, and the latent style data (`cbLSD`, 4,
|
||||
/// then the LSDs) follows it: a header that leaves it out doesn't make `cbLSD` the complex-script
|
||||
/// font.
|
||||
#[test]
|
||||
fn the_complex_script_standard_font_is_read_only_when_present() {
|
||||
let header = |tail: &[u16]| -> Vec<u8> {
|
||||
// cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
|
||||
// nVerBuiltInNamesWhenSaved; ASCII, East Asian, other.
|
||||
let words = [&[0u16, 10, 0, 1, 0, 0, 3, 4, 3][..], tail].concat();
|
||||
let stshi: Vec<u8> = words.iter().flat_map(|w| w.to_le_bytes()).collect();
|
||||
[(stshi.len() as u16).to_le_bytes().to_vec(), stshi].concat()
|
||||
};
|
||||
let bidi = |tail: &[u16]| {
|
||||
let b = header(tail);
|
||||
crate::stsh::standard_fonts(&b, 0, b.len() as u32).bidi
|
||||
};
|
||||
assert_eq!(bidi(&[2]), Some(2), "a header that ends with it (Word 97)");
|
||||
assert_eq!(bidi(&[4]), Some(4), "font 4, at the end of the header");
|
||||
assert_eq!(bidi(&[2, 4, 0, 0]), Some(2), "followed by the latent style data");
|
||||
assert_eq!(bidi(&[4, 4, 0, 0]), Some(4), "font 4, followed by the latent style data");
|
||||
assert_eq!(bidi(&[4, 0, 0]), None, "left out: cbLSD is in its place");
|
||||
assert_eq!(bidi(&[]), None, "a header too short for it");
|
||||
let b = header(&[4, 0, 0]);
|
||||
let fonts = crate::stsh::standard_fonts(&b, 0, b.len() as u32);
|
||||
assert_eq!((fonts.ascii, fonts.east_asian, fonts.other), (Some(3), Some(4), Some(3)), "the other fonts are read all the same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_props_and_heading_style() {
|
||||
let spec = RichSpec {
|
||||
@@ -945,6 +1101,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<u8>)> = (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 {
|
||||
@@ -1110,6 +1292,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.
|
||||
|
||||
@@ -1370,6 +1599,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";
|
||||
@@ -2302,6 +2541,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):
|
||||
@@ -2366,17 +2622,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)
|
||||
};
|
||||
@@ -2384,13 +2644,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`
|
||||
@@ -2480,7 +2743,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
assert_eq!(linked.first().map(|r| r.len), Some(n));
|
||||
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
|
||||
// what the copies charge to the work budget, which a busy machine can't skew.
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
@@ -2493,7 +2757,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let mut result = super::FieldResult::default();
|
||||
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
@@ -2567,3 +2832,51 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
|
||||
assert_eq!(work.left.get(), 1);
|
||||
assert_eq!(work.dropped(), [super::Cost::Formatting]);
|
||||
}
|
||||
|
||||
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
|
||||
/// (365 bytes each: 4 million result characters took 1.4 GB).
|
||||
#[test]
|
||||
fn a_field_result_stores_one_props_per_run() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut r = super::FieldResult::default();
|
||||
for _ in 0..200_000 {
|
||||
r.push('x', CharProps::default());
|
||||
}
|
||||
assert_eq!(r.runs.len(), 1);
|
||||
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
|
||||
r.push('\u{e9}', bold.clone());
|
||||
r.push('y', bold);
|
||||
r.push('z', CharProps::default());
|
||||
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
|
||||
assert_eq!(r.text.len(), 200_004);
|
||||
}
|
||||
|
||||
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
|
||||
/// links all of it; a plain field keeps its result text whole.
|
||||
#[test]
|
||||
fn field_results_keep_their_text_and_runs() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut result = super::FieldResult::default();
|
||||
for (c, p) in
|
||||
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
|
||||
{
|
||||
result.push(c, p);
|
||||
}
|
||||
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
|
||||
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
|
||||
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
|
||||
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
|
||||
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
|
||||
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
|
||||
assert!(
|
||||
p.objects
|
||||
.iter()
|
||||
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
|
||||
"{:?}",
|
||||
p.objects
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -32,6 +32,7 @@ getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -25,11 +25,23 @@ pub fn read(root: &El) -> Vec<CustomProp> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn sniff_xml(bytes: &[u8]) -> XmlKind {
|
||||
|
||||
/// Read a Flat OPC file.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
crate::read::read_opened(&unpack(bytes)?)
|
||||
crate::read::read_opened(&unpack(bytes)?, None)
|
||||
}
|
||||
|
||||
/// The data element of the part being read.
|
||||
|
||||
+23
-8
@@ -32,7 +32,7 @@ pub use crypt::{
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
pub use read::read;
|
||||
pub use read::{AltChunk, ChunkImporter, ReadOptions, read};
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use wordml2003_write::write as write_wordml2003;
|
||||
pub use write::{write, write_as};
|
||||
@@ -56,11 +56,19 @@ pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
|
||||
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
|
||||
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
|
||||
/// directory and at most `max_part` bytes of that part are read: for listing templates by name
|
||||
/// without reading the whole package.
|
||||
pub fn package_title<R: std::io::Read + std::io::Seek>(package: R, max_part: u64) -> Option<String> {
|
||||
/// directory and at most `max_part` bytes of that part are inflated: for listing templates by
|
||||
/// name without unpacking the whole package. A package over 64 MiB, or one declaring more entries
|
||||
/// than `MAX_ENTRIES` (20,000), has none (#3660).
|
||||
pub fn package_title<R: std::io::Read>(package: R, max_part: u64) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let mut zip = zip::ZipArchive::new(package).ok()?;
|
||||
const MAX_PACKAGE: u64 = 64 << 20;
|
||||
let mut bytes = Vec::new();
|
||||
package.take(MAX_PACKAGE + 1).read_to_end(&mut bytes).ok()?;
|
||||
if bytes.len() as u64 > MAX_PACKAGE {
|
||||
return None;
|
||||
}
|
||||
wordcraft_doc::zip_guard::check(&bytes, package::MAX_ENTRIES as u64).ok()?;
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
|
||||
let part = zip.by_name("docProps/core.xml").ok()?;
|
||||
let mut xml = Vec::new();
|
||||
part.take(max_part).read_to_end(&mut xml).ok()?;
|
||||
@@ -84,11 +92,18 @@ pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm";
|
||||
/// `password` first. An encrypted package without a password gives
|
||||
/// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`].
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
read_with(bytes, &ReadOptions { password, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Read a package with options: the password of an encrypted one ([`read_with_password`]) and
|
||||
/// an importer for `w:altChunk` content in formats other than WordprocessingML (see
|
||||
/// [`ChunkImporter`]).
|
||||
pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
if is_encrypted(bytes) {
|
||||
let package = zeroize::Zeroizing::new(decrypt(bytes, password)?);
|
||||
return read::read_package(&package);
|
||||
let package = zeroize::Zeroizing::new(decrypt(bytes, opts.password)?);
|
||||
return read::read_package(&package, opts.chunks);
|
||||
}
|
||||
read(bytes)
|
||||
read::read_package(bytes, opts.chunks)
|
||||
}
|
||||
|
||||
/// Which kind of WordprocessingML package to write. The main part's content type differs per
|
||||
|
||||
@@ -47,6 +47,7 @@ pub mod rt {
|
||||
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
|
||||
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument";
|
||||
pub const AF_CHUNK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk";
|
||||
}
|
||||
|
||||
/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8).
|
||||
@@ -212,6 +213,8 @@ impl Package {
|
||||
}
|
||||
|
||||
pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result<Package, DocxError> {
|
||||
// Before `zip` parses every entry the end record declares (#3660).
|
||||
wordcraft_doc::zip_guard::check(bytes, MAX_ENTRIES as u64).map_err(DocxError::Limit)?;
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| DocxError::Zip(e.to_string()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(DocxError::Limit(format!("{} zip entries", zip.len())));
|
||||
@@ -252,6 +255,11 @@ impl Package {
|
||||
Ok(Package::new(files))
|
||||
}
|
||||
|
||||
/// The bytes of all its parts.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.values().map(|f| f.len() as u64).sum()
|
||||
}
|
||||
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
@@ -524,6 +532,21 @@ mod tests {
|
||||
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_declaring_too_many_entries_is_refused_before_zip_reads_them() {
|
||||
// #3660: a ZIP64 end record declaring more than MAX_ENTRIES.
|
||||
let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(MAX_ENTRIES as u64 + 1);
|
||||
assert!(matches!(Package::open(&bomb), Err(DocxError::Limit(e)) if e.contains("declares")));
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(bomb), 1 << 20), None);
|
||||
// A real package still opens, and its title still reads.
|
||||
let z = zip_of(&[(
|
||||
"docProps/core.xml",
|
||||
br#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Plan</dc:title></cp:coreProperties>"#.to_vec(),
|
||||
)]);
|
||||
assert!(Package::open(&z).is_ok());
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
@@ -587,7 +610,7 @@ mod tests {
|
||||
("word/media/pic.png", b"\x89PNG picture bytes".to_vec()),
|
||||
]);
|
||||
let pkg = Package::open(&z).unwrap();
|
||||
let doc = crate::read::read_opened(&pkg).unwrap();
|
||||
let doc = crate::read::read_opened(&pkg, None).unwrap();
|
||||
let held = doc.media.values().next().expect("the picture is read");
|
||||
assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy");
|
||||
assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged");
|
||||
|
||||
+26
-11
@@ -1,6 +1,7 @@
|
||||
//! DOCX → [`Document`].
|
||||
|
||||
pub(crate) mod chart;
|
||||
mod chunk;
|
||||
pub(crate) mod diagram;
|
||||
mod drawing_color;
|
||||
mod embed;
|
||||
@@ -13,7 +14,7 @@ mod shape_style;
|
||||
mod story;
|
||||
mod wordart;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events};
|
||||
@@ -46,6 +47,9 @@ pub(crate) use meta::PROTECTION_HASH_ATTRS;
|
||||
pub(crate) use styles::LOWER_NAMES;
|
||||
use toc::{is_toc, toc_control_to_toc, toc_field_to_heading, unstyle_toc_links};
|
||||
|
||||
use chunk::ChunkState;
|
||||
pub use chunk::{AltChunk, ChunkImporter, ReadOptions};
|
||||
|
||||
/// Most fonts read from a font table, and the longest name (hostile files).
|
||||
const MAX_TABLE_FONTS: usize = 2000;
|
||||
const MAX_FONT_NAME: usize = 256;
|
||||
@@ -87,12 +91,12 @@ pub(crate) struct Reader<'p> {
|
||||
pub bookmarks: HashMap<String, String>,
|
||||
/// Restrict Editing exception ranges (`w:permStart`): the file's id → ours.
|
||||
pub perms: HashMap<String, u32>,
|
||||
/// The next permission range id to give out (chunks' ranges take theirs from it too).
|
||||
pub next_perm: u32,
|
||||
/// Chart and diagram parts by path, parsed once.
|
||||
graphic_parts: HashMap<String, Option<Arc<El>>>,
|
||||
/// Charts and diagrams by (kind, part path, width bits, height bits), built once.
|
||||
graphics: HashMap<(GraphicKind, String, u32, u32), Arc<Graphic>>,
|
||||
/// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]).
|
||||
graphic_budget: usize,
|
||||
/// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id
|
||||
/// was kept while reading (those not glued to are dropped at the end).
|
||||
pub glued: HashSet<u32>,
|
||||
@@ -103,6 +107,8 @@ pub(crate) struct Reader<'p> {
|
||||
content_types: Option<ContentTypes>,
|
||||
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
|
||||
embedded: EmbeddedManifest,
|
||||
/// What alternative format chunks may still bring in, and how (see [`chunk`]).
|
||||
chunks: ChunkState<'p>,
|
||||
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box
|
||||
/// holding the text), text box part). See [`link_text_boxes`].
|
||||
pub txbx_links: Vec<(u32, u32, u32)>,
|
||||
@@ -125,17 +131,24 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
if crate::is_encrypted(bytes) {
|
||||
return crate::read_with_password(bytes, None);
|
||||
}
|
||||
read_package(bytes)
|
||||
read_package(bytes, None)
|
||||
}
|
||||
|
||||
/// [`read`] for bytes known not to be an encrypted package.
|
||||
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
read_opened(&Package::open(bytes)?)
|
||||
/// [`read`] for bytes known not to be an encrypted package; `chunks` imports alternative format
|
||||
/// chunks in formats other than WordprocessingML.
|
||||
pub(crate) fn read_package(bytes: &[u8], chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
|
||||
read_opened(&Package::open(bytes)?, chunks)
|
||||
}
|
||||
|
||||
/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted
|
||||
/// Word 2003 XML document).
|
||||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
pub(crate) fn read_opened(pkg: &Package, chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
|
||||
read_opened_in(pkg, ChunkState::new(chunks)).map(|(doc, ..)| doc)
|
||||
}
|
||||
|
||||
/// [`read_opened`] with what chunks may still bring in; also gives the chunks, chunk bytes and
|
||||
/// graphic work left of that.
|
||||
fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, u64, usize), DocxError> {
|
||||
let root_rels = pkg.rels("");
|
||||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let Some(root) = pkg.xml_strict(&main)? else {
|
||||
@@ -164,9 +177,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
comments_ended: HashSet::new(),
|
||||
bookmarks: HashMap::new(),
|
||||
perms: HashMap::new(),
|
||||
next_perm: 0,
|
||||
graphic_parts: HashMap::new(),
|
||||
graphics: HashMap::new(),
|
||||
graphic_budget: MAX_GRAPHIC_WORK,
|
||||
glued: HashSet::new(),
|
||||
shape_ids: false,
|
||||
main: main.clone(),
|
||||
@@ -175,6 +188,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
txbx_links: Vec::new(),
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
chunks,
|
||||
};
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
r.pc.minor_font = r.doc.settings.minor_font.clone();
|
||||
@@ -267,7 +281,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
if let Some(body) = root.child("w:body") {
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth);
|
||||
r.flush_pending(&mut sc, &mut blocks);
|
||||
r.doc.body = blocks;
|
||||
unstyle_toc_links(&mut r.doc);
|
||||
@@ -321,9 +335,10 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
if r.shape_ids {
|
||||
keep_glued_ids(&mut r.doc, &r.glued);
|
||||
}
|
||||
let (left, bytes, graphic) = (r.chunks.left, r.chunks.bytes, r.chunks.graphic);
|
||||
let mut doc = r.doc;
|
||||
doc.ensure_nonempty();
|
||||
Ok(doc)
|
||||
Ok((doc, left, bytes, graphic))
|
||||
}
|
||||
|
||||
/// Most move ranges open at once, and most move names tracked, per file.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! `w:altChunk` (ECMA-376 §17.17.2.1): content kept in another format (HTML, MHT, RTF, plain
|
||||
//! text, another WordprocessingML document) that Word brings into the document where the chunk
|
||||
//! stands when it opens the file. A nested WordprocessingML package is read here; other formats
|
||||
//! go to the caller's [`ChunkImporter`] (the format crates sit beside this one), and without one
|
||||
//! the chunk is left out.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Block, Blocks, Document};
|
||||
|
||||
use super::story::StoryCtx;
|
||||
use super::{Reader, part_of};
|
||||
use crate::package::{ContentTypes, MAX_PART, MAX_TOTAL, Package, Rels, rt};
|
||||
use crate::xml::El;
|
||||
|
||||
/// Most chunks brought in from one file (nested documents' chunks included).
|
||||
pub(crate) const MAX_CHUNKS: usize = 256;
|
||||
/// Deepest a WordprocessingML chunk may nest in another.
|
||||
pub(crate) const MAX_CHUNK_NESTING: u8 = 4;
|
||||
|
||||
/// An alternative format chunk's content, as a [`ChunkImporter`] gets it.
|
||||
pub struct AltChunk<'a> {
|
||||
/// The part's content type (`application/xhtml+xml`, `message/rfc822`, `application/rtf`,
|
||||
/// `text/plain`…), empty when the package doesn't give one.
|
||||
pub content_type: &'a str,
|
||||
/// The part's name (`word/afchunk.mht`): its extension tells the format when the content type
|
||||
/// doesn't.
|
||||
pub part: &'a str,
|
||||
pub bytes: &'a [u8],
|
||||
}
|
||||
|
||||
/// Turns a chunk the docx reader can't read itself into a document whose body takes the chunk's
|
||||
/// place; `None` leaves the chunk out.
|
||||
pub type ChunkImporter<'a> = &'a dyn Fn(&AltChunk) -> Option<Document>;
|
||||
|
||||
/// How [`crate::read_with`] reads a package.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ReadOptions<'a> {
|
||||
/// The password of an encrypted package.
|
||||
pub password: Option<&'a str>,
|
||||
/// Imports `w:altChunk` content in formats other than WordprocessingML.
|
||||
pub chunks: Option<ChunkImporter<'a>>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Bring the chunk `e` names into `out` where it stands (`depth`: of the XML around it).
|
||||
pub(crate) fn read_alt_chunk(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, out: &mut Blocks, depth: usize) {
|
||||
let Some(part) = e.attr("r:id").and_then(|id| part_of(rels, id, rt::AF_CHUNK)) else { return };
|
||||
let pkg = self.pkg;
|
||||
let Some(bytes) = pkg.get(&part) else {
|
||||
log::warn!("docx: alternative format chunk {part} is missing");
|
||||
return;
|
||||
};
|
||||
// Every chunk's bytes count against one budget for the whole file, however often the same
|
||||
// part is named and however deep chunks nest.
|
||||
if self.chunks.left == 0 || bytes.len() as u64 > self.chunks.bytes {
|
||||
log::warn!("docx: more than {MAX_CHUNKS} alternative format chunks or {MAX_TOTAL} bytes of them; the rest are left out");
|
||||
return;
|
||||
}
|
||||
self.chunks.left -= 1;
|
||||
self.chunks.bytes -= bytes.len() as u64;
|
||||
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part).unwrap_or("").to_string();
|
||||
let chunk = AltChunk { content_type: &ct, part: &part, bytes };
|
||||
// A WordprocessingML chunk is a package; one that isn't Word's fails to read (logged).
|
||||
let is_docx = bytes.starts_with(b"PK\x03\x04");
|
||||
let doc = if is_docx { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) };
|
||||
let Some(mut doc) = doc else {
|
||||
log::warn!("docx: alternative format chunk {part} ({ct}) is in a format WordCraft can't bring in");
|
||||
return;
|
||||
};
|
||||
// The chunk flows in the section it stands in; its own section breaks don't come along.
|
||||
for b in &mut doc.body {
|
||||
if let Block::Para(p) = &**b
|
||||
&& p.section.is_some()
|
||||
&& let Block::Para(p) = Arc::make_mut(b)
|
||||
{
|
||||
p.section = None;
|
||||
}
|
||||
}
|
||||
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
|
||||
// is their own already).
|
||||
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
|
||||
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
|
||||
// Bookmarks and comment marks read before the chunk stay before it.
|
||||
self.flush_pending_before(sc, out, &mut blocks);
|
||||
out.extend(blocks.into_iter().map(Arc::new));
|
||||
}
|
||||
|
||||
/// A WordprocessingML chunk: its document, read with the same importer and what is left of
|
||||
/// the budgets, its XML depth counting on from `depth`.
|
||||
fn read_nested(&mut self, bytes: &[u8], depth: usize) -> Option<Document> {
|
||||
if self.chunks.nesting >= MAX_CHUNK_NESTING {
|
||||
log::warn!("docx: alternative format chunks nest deeper than {MAX_CHUNK_NESTING}; the inner ones are left out");
|
||||
return None;
|
||||
}
|
||||
let pkg =
|
||||
Package::open_limited(bytes, MAX_PART, self.chunks.bytes).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
|
||||
self.chunks.bytes = self.chunks.bytes.saturating_sub(pkg.size());
|
||||
let state = ChunkState { nesting: self.chunks.nesting + 1, depth: depth + 1, ..self.chunks };
|
||||
let (doc, left, bytes, graphic) =
|
||||
super::read_opened_in(&pkg, state).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
|
||||
self.chunks.left = left;
|
||||
self.chunks.bytes = bytes;
|
||||
self.chunks.graphic = graphic;
|
||||
Some(doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reader may still bring in from chunks.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ChunkState<'a> {
|
||||
/// Chunks left in the file's budget ([`MAX_CHUNKS`]).
|
||||
pub left: usize,
|
||||
/// Chunk bytes (nested packages unpacked) left in the file's budget.
|
||||
pub bytes: u64,
|
||||
/// Graphic work left in the file's budget ([`super::MAX_GRAPHIC_WORK`]): nested documents'
|
||||
/// charts and diagrams count against it too.
|
||||
pub graphic: usize,
|
||||
/// How deep in nested WordprocessingML chunks this reader is.
|
||||
pub nesting: u8,
|
||||
/// The XML depth its stories start at (a nested document's: that of the chunk naming it).
|
||||
pub depth: usize,
|
||||
pub import: Option<ChunkImporter<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> ChunkState<'a> {
|
||||
pub fn new(import: Option<ChunkImporter<'a>>) -> Self {
|
||||
ChunkState { left: MAX_CHUNKS, bytes: MAX_TOTAL, graphic: super::MAX_GRAPHIC_WORK, nesting: 0, depth: 0, import }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{Block, Document, InlineObject};
|
||||
|
||||
use super::ChunkState;
|
||||
use crate::package::Package;
|
||||
|
||||
/// A package whose body is `body`, with the document relationship `rel` (id, type, target) to
|
||||
/// the part `(name, bytes)`.
|
||||
fn package(body: &str, (id, ty, target): (&str, &str, &str), (name, bytes): (&str, &[u8])) -> Vec<u8> {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
||||
let rels =
|
||||
|inner: &str| format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{inner}</Relationships>"#);
|
||||
let rel = |id: &str, ty: &str, target: &str| {
|
||||
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
|
||||
};
|
||||
let files: [(&str, Vec<u8>); 4] = [
|
||||
("_rels/.rels", rels(&rel("rId1", "officeDocument", "word/document.xml")).into_bytes()),
|
||||
("word/document.xml", format!(r#"<w:document {ns}><w:body>{body}</w:body></w:document>"#).into_bytes()),
|
||||
("word/_rels/document.xml.rels", rels(&rel(id, ty, target)).into_bytes()),
|
||||
(name, bytes.to_vec()),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in files {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// Whether the body's first paragraph's chart is drawn.
|
||||
fn drawn(d: &Document) -> bool {
|
||||
let Some(Block::Para(p)) = d.body.first().map(|b| &**b) else { panic!("no paragraph") };
|
||||
p.objects.iter().any(|o| matches!(o, InlineObject::Graphic { graphic, .. } if !graphic.items.is_empty()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_documents_charts_count_against_the_files_graphic_budget() {
|
||||
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let drawing = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rIdChart"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
|
||||
let inner = package(drawing, ("rIdChart", "chart", "charts/chart1.xml"), ("word/charts/chart1.xml", chart.as_bytes()));
|
||||
let outer = package(r#"<w:altChunk r:id="rIdC"/>"#, ("rIdC", "aFChunk", "afchunk.docx"), ("word/afchunk.docx", &inner));
|
||||
let pkg = Package::open(&outer).unwrap();
|
||||
let read = |graphic| {
|
||||
let (d, .., left) = super::super::read_opened_in(&pkg, ChunkState { graphic, ..ChunkState::new(None) }).unwrap();
|
||||
(drawn(&d), left)
|
||||
};
|
||||
let (shown, left) = read(super::super::MAX_GRAPHIC_WORK);
|
||||
assert!(shown);
|
||||
assert!(left < super::super::MAX_GRAPHIC_WORK, "the nested chart's work is the file's");
|
||||
// With the file's budget spent, the nested document doesn't get one of its own.
|
||||
assert_eq!(read(0), (false, 0));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph,
|
||||
PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
|
||||
Arc, Blocks, Comment, DocxError, El, HashMap, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, PartKind, Reader, Rels,
|
||||
Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
@@ -120,7 +120,7 @@ impl Reader<'_> {
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.current_note = Some((kind, id));
|
||||
self.read_blocks(&mut sc, n, &rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, n, &rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
self.current_note = None;
|
||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||
@@ -152,7 +152,7 @@ impl Reader<'_> {
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.in_note_sep = true;
|
||||
self.read_blocks(&mut sc, n, rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, n, rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
self.in_note_sep = false;
|
||||
if blocks.is_empty() {
|
||||
@@ -188,11 +188,7 @@ impl Reader<'_> {
|
||||
let rels = self.pkg.rels(path);
|
||||
let mut by_para: HashMap<String, u32> = HashMap::new();
|
||||
let mut next_id = 0u32;
|
||||
let mut entries: BTreeMap<u32, Comment> = BTreeMap::new();
|
||||
// Ids given before this part, looked up rather than scanned for every generated id: a
|
||||
// scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in
|
||||
// `entries`.
|
||||
let given: HashSet<u32> = self.comment_map.values().copied().collect();
|
||||
// Straight into the document, beside the comments chunks in notes brought in.
|
||||
for c in root.children("w:comment") {
|
||||
if self.doc.parts.len() >= MAX_PARTS {
|
||||
break;
|
||||
@@ -202,23 +198,26 @@ impl Reader<'_> {
|
||||
continue;
|
||||
}
|
||||
let id = match file_id.parse::<u32>() {
|
||||
Ok(v) if !entries.contains_key(&v) => v,
|
||||
Ok(v) if !self.doc.comments.contains_key(&v) => v,
|
||||
_ => {
|
||||
while entries.contains_key(&next_id) || given.contains(&next_id) {
|
||||
// Every id handed out so far is a key of `doc.comments` (inserted below).
|
||||
while self.doc.comments.contains_key(&next_id) {
|
||||
next_id += 1;
|
||||
}
|
||||
next_id
|
||||
}
|
||||
};
|
||||
// Taken before its text is read: a chunk there brings comments of its own.
|
||||
self.doc.comments.insert(id, Comment::default());
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.read_blocks(&mut sc, c, &rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
let part = self.doc.add_part(PartKind::Comment, blocks);
|
||||
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
|
||||
by_para.insert(pid.to_string(), id);
|
||||
}
|
||||
entries.insert(
|
||||
self.doc.comments.insert(
|
||||
id,
|
||||
Comment {
|
||||
author: c.attr("w:author").unwrap_or("").to_string(),
|
||||
@@ -239,7 +238,7 @@ impl Reader<'_> {
|
||||
let Some(cid) = e.attr("w15:paraId").and_then(|p| by_para.get(p)).copied() else { continue };
|
||||
let parent = e.attr("w15:paraIdParent").and_then(|p| by_para.get(p)).copied().filter(|p| *p != cid);
|
||||
let done = e.attr("w15:done").is_some_and(|v| v == "1" || v == "true");
|
||||
if let Some(c) = entries.get_mut(&cid) {
|
||||
if let Some(c) = self.doc.comments.get_mut(&cid) {
|
||||
c.parent = parent;
|
||||
c.resolved = done;
|
||||
}
|
||||
@@ -258,7 +257,7 @@ impl Reader<'_> {
|
||||
for e in x.els().filter(|e| e.local() == "commentId") {
|
||||
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
|
||||
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
|
||||
if let Some(c) = entries.get_mut(&cid) {
|
||||
if let Some(c) = self.doc.comments.get_mut(&cid) {
|
||||
c.durable_id = Some(durable.to_ascii_uppercase());
|
||||
by_durable.insert(durable.to_ascii_uppercase(), cid);
|
||||
}
|
||||
@@ -270,12 +269,11 @@ impl Reader<'_> {
|
||||
for e in x.els().filter(|e| e.local() == "commentExtensible") {
|
||||
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
|
||||
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
|
||||
if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) {
|
||||
if let (Some(c), Some(date)) = (self.doc.comments.get_mut(&cid), date) {
|
||||
c.date_utc = Some(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.doc.comments = entries;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ impl Reader<'_> {
|
||||
sc.in_hf = true;
|
||||
let mut blocks = Blocks::new();
|
||||
self.hf_depth += 1;
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
|
||||
self.hf_depth = self.hf_depth.saturating_sub(1);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||
|
||||
@@ -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,
|
||||
@@ -33,6 +33,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
|
||||
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
|
||||
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
|
||||
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
|
||||
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
|
||||
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
|
||||
}
|
||||
@@ -172,8 +173,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<Rgb> {
|
||||
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 +201,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 +209,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<Rgb> {
|
||||
// `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 +255,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 +288,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 {
|
||||
@@ -328,6 +334,7 @@ impl PropCtx {
|
||||
"w:widowControl" => p.widow_control = Some(on_off(k)),
|
||||
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
|
||||
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
|
||||
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
@@ -338,6 +345,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 +369,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<TabStop> = k.children("w:tab").take(64).filter_map(tab_stop).collect();
|
||||
@@ -476,29 +484,32 @@ fn shd_fill(e: &El) -> Option<Rgb> {
|
||||
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 +543,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 +649,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 +686,7 @@ pub struct HfRef {
|
||||
}
|
||||
|
||||
/// `w:sectPr` (header/footer references are returned for the caller to resolve).
|
||||
pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
let mut s = SectionProps::default();
|
||||
let mut refs = Vec::new();
|
||||
for k in e.els() {
|
||||
@@ -754,8 +765,26 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
if let Some(f) = k.attr("w:fmt") {
|
||||
s.page_num_format = NumFormat::from_ooxml(f);
|
||||
}
|
||||
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
|
||||
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
|
||||
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
|
||||
}
|
||||
"w:paperSrc" => {
|
||||
let tray = |name| {
|
||||
let value = k.attr(name)?.trim();
|
||||
let digits = value.strip_prefix('+').unwrap_or(value);
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
|
||||
};
|
||||
s.paper_src_first = tray("w:first");
|
||||
s.paper_src_other = tray("w:other");
|
||||
}
|
||||
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
|
||||
"w15:footnoteColumns" => {
|
||||
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
|
||||
}
|
||||
"w:lnNumType" => {
|
||||
let mut l = LineNumbering::default();
|
||||
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
|
||||
@@ -780,7 +809,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
_ => 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) {
|
||||
|
||||
@@ -166,9 +166,9 @@ fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice"))
|
||||
}
|
||||
|
||||
/// Gather `name` children (rows, cells), looking through `w:sdt`/`w:customXml` wrappers; each
|
||||
/// with the content controls that open before it and how many close after it. `pending` holds
|
||||
/// controls opened but not yet given to an element.
|
||||
/// Gather `name` children (rows, cells), looking through content controls, custom XML and the
|
||||
/// selected alternate-content branch; each with the content controls that open before it and how
|
||||
/// many close after it. `pending` holds controls opened but not yet given to an element.
|
||||
fn collect<'a>(e: &'a El, name: &str, out: &mut Vec<(&'a El, ControlWrap)>, pending: &mut Vec<ContentControl>, depth: usize) {
|
||||
if depth > 8 {
|
||||
return;
|
||||
@@ -194,6 +194,10 @@ fn collect<'a>(e: &'a El, name: &str, out: &mut Vec<(&'a El, ControlWrap)>, pend
|
||||
}
|
||||
} else if k.name == "w:customXml" {
|
||||
collect(k, name, out, pending, depth + 1);
|
||||
} else if k.name == "mc:AlternateContent"
|
||||
&& let Some(branch) = children_of_choice(k)
|
||||
{
|
||||
collect(branch, name, out, pending, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -51,6 +51,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"w:sdt" => self.read_block_sdt(sc, e, rels, out, depth),
|
||||
"w:altChunk" => self.read_alt_chunk(sc, e, rels, out, depth),
|
||||
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => self.read_blocks(sc, e, rels, out, depth + 1),
|
||||
// Math is allowed straight in a story (EG_BlockLevelElts takes EG_RunLevelElements,
|
||||
// which takes EG_MathContent): it gets a paragraph of its own (#1273).
|
||||
@@ -135,6 +136,21 @@ impl Reader<'_> {
|
||||
sc.pending.clear();
|
||||
}
|
||||
|
||||
/// Markers read before content placed whole (a chunk's `next`) stay before it: at the end of
|
||||
/// the paragraph before, or after a table or with none, at the start of the content's first
|
||||
/// paragraph, as a paragraph read there would take them (with a table first, the next
|
||||
/// paragraph read takes them).
|
||||
pub fn flush_pending_before(&mut self, sc: &mut StoryCtx, out: &mut Blocks, next: &mut [Block]) {
|
||||
if out.last().is_some_and(|b| matches!(**b, Block::Para(_))) {
|
||||
return self.flush_pending(sc, out);
|
||||
}
|
||||
if let Some(Block::Para(p)) = next.first_mut() {
|
||||
for o in sc.pending.drain(..).rev() {
|
||||
let _ = p.insert_object(0, o, &CharProps::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn marker(&mut self, e: &El) -> Option<InlineObject> {
|
||||
let id = e.attr("w:id").map(str::trim).unwrap_or("").to_string();
|
||||
match e.name.as_str() {
|
||||
@@ -161,8 +177,15 @@ impl Reader<'_> {
|
||||
if self.perms.len() >= 100_000 {
|
||||
return None;
|
||||
}
|
||||
let n = self.perms.len() as u32;
|
||||
let id = *self.perms.entry(id).or_insert(n);
|
||||
let id = match self.perms.get(&id) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
let n = self.next_perm;
|
||||
self.next_perm = n.saturating_add(1);
|
||||
self.perms.insert(id, n);
|
||||
n
|
||||
}
|
||||
};
|
||||
let text = |a: &str| e.attr(a).filter(|v| v.len() <= 256).unwrap_or("").to_string();
|
||||
let (group, editor) = (text("w:edGrp"), text("w:ed"));
|
||||
if group.is_empty() && editor.is_empty() {
|
||||
@@ -266,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 {
|
||||
|
||||
@@ -218,8 +218,8 @@ impl Reader<'_> {
|
||||
if let Some(g) = self.graphics.get(&key) {
|
||||
return g.clone();
|
||||
}
|
||||
let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
|
||||
self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items));
|
||||
let items = if self.chunks.graphic == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
|
||||
self.chunks.graphic = self.chunks.graphic.saturating_sub(graphic_work(&items));
|
||||
// A chart or SmartArt that is exactly what WordCraft writes for its model can be edited
|
||||
// (and is written from the model); any other stays as it was read.
|
||||
let chart = match kind {
|
||||
@@ -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
|
||||
@@ -499,10 +499,26 @@ impl Reader<'_> {
|
||||
.and_then(|g| preset_adj(g, "adj"))
|
||||
.and_then(|v| u32::try_from(v.clamp(0, i64::from(wordcraft_doc::connector::MAX_CORNER))).ok());
|
||||
}
|
||||
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
|
||||
let arrow = |tag: &str| ln.and_then(|l| l.child(tag)).and_then(|e| e.attr("type")).is_some_and(|t| t != "none");
|
||||
extra.arrow_start = arrow("a:headEnd");
|
||||
extra.arrow_end = arrow("a:tailEnd");
|
||||
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle of its
|
||||
// width and length.
|
||||
let arrow = |tag: &str| {
|
||||
let e = ln.and_then(|l| l.child(tag))?;
|
||||
let kind = e.attr("type").filter(|t| *t != "none")?;
|
||||
let size = |name: &str| wordcraft_doc::connector::ArrowSize::from_ooxml(e.attr(name).unwrap_or("med"));
|
||||
Some(wordcraft_doc::connector::ArrowStyle {
|
||||
kind: wordcraft_doc::connector::ArrowKind::from_ooxml(kind),
|
||||
width: size("w"),
|
||||
length: size("len"),
|
||||
})
|
||||
};
|
||||
if let Some(style) = arrow("a:headEnd") {
|
||||
extra.arrow_start = true;
|
||||
extra.start_style = style;
|
||||
}
|
||||
if let Some(style) = arrow("a:tailEnd") {
|
||||
extra.arrow_end = true;
|
||||
extra.end_style = style;
|
||||
}
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.36, §20.1.2.2.13).
|
||||
if let Some(cn) = wsp.child("wps:cNvCnPr") {
|
||||
let end = |tag: &str| {
|
||||
@@ -540,7 +556,7 @@ impl Reader<'_> {
|
||||
let mut blocks = Blocks::new();
|
||||
// Shapes in the text aren't members of a group the box may be in.
|
||||
let group = self.group_fill.take();
|
||||
self.read_blocks(&mut inner, content, rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut inner, content, rels, &mut blocks, self.chunks.depth);
|
||||
self.group_fill = group;
|
||||
self.flush_pending(&mut inner, &mut blocks);
|
||||
self.doc.add_part(PartKind::TextBox, blocks)
|
||||
|
||||
@@ -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<String> {
|
||||
// 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 ----
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -295,11 +298,41 @@ impl Reader<'_> {
|
||||
}
|
||||
let kind = kind?;
|
||||
let mut extra = ShapeExtra { cap, ..Default::default() };
|
||||
// A horizontal rule (`o:hr`, a pasted `<hr>`): across `o:hrpct` tenths of a percent of its
|
||||
// line, or its own width (`o:hrpct="0"`); a width of 0 with no percentage is the whole
|
||||
// line. `o:hralign` places a shorter one (Word, measured: 50 % centred, left, right).
|
||||
// Only a rule in the text, shown: a positioned or hidden one stays a plain shape, which
|
||||
// keeps its placement and visibility when saved.
|
||||
if attr("o:hr").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1")) && float.wrap == Wrap::Inline && !float.hidden {
|
||||
let tenths = attr("o:hrpct").and_then(|v| v.trim().parse::<f32>().ok()).filter(|p| p.is_finite() && *p > 0.0);
|
||||
let pct = tenths.map_or(if w > 0.0 { 0.0 } else { 100.0 }, |t| (t / 10.0).min(100.0));
|
||||
let align = match attr("o:hralign").map(|a| a.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("center") => wordcraft_doc::props::Align::Center,
|
||||
Some("right") => wordcraft_doc::props::Align::Right,
|
||||
_ => wordcraft_doc::props::Align::Left,
|
||||
};
|
||||
let standard = attr("o:hrstd").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1"));
|
||||
extra.hr = Some(wordcraft_doc::connector::HorizontalRule { pct, align, standard });
|
||||
}
|
||||
if kind == ShapeKind::Line {
|
||||
// Arrowheads (`v:stroke` startarrow / endarrow): any but `none` is drawn.
|
||||
let arrow = |name: &str| shape.child("v:stroke").and_then(|s| s.attr(name)).is_some_and(|v| v != "none");
|
||||
extra.arrow_start = arrow("startarrow");
|
||||
extra.arrow_end = arrow("endarrow");
|
||||
// Arrowheads (`v:stroke` startarrow / endarrow, with their width and length): any but
|
||||
// `none` is drawn.
|
||||
use wordcraft_doc::connector::{ArrowKind, ArrowSize, ArrowStyle};
|
||||
let stroke = shape.child("v:stroke");
|
||||
let arrow = |end: &str| {
|
||||
let s = stroke?;
|
||||
let kind = s.attr(&format!("{end}arrow")).filter(|v| *v != "none")?;
|
||||
let size = |what: &str| ArrowSize::from_vml(s.attr(&format!("{end}arrow{what}")).unwrap_or("medium"));
|
||||
Some(ArrowStyle { kind: ArrowKind::from_vml(kind), width: size("width"), length: size("length") })
|
||||
};
|
||||
if let Some(style) = arrow("start") {
|
||||
extra.arrow_start = true;
|
||||
extra.start_style = style;
|
||||
}
|
||||
if let Some(style) = arrow("end") {
|
||||
extra.arrow_end = true;
|
||||
extra.end_style = style;
|
||||
}
|
||||
}
|
||||
if shape.local() == "line" {
|
||||
// A line runs `from` → `to` (points, offsets from where its style places it). A preset
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u32>),
|
||||
/// A colour of its own, lightened to `tint` when set.
|
||||
Rgb(Rgb, Option<u32>),
|
||||
/// 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<Sh> {
|
||||
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<Sh> {
|
||||
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<Sh> {
|
||||
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<Sh> {
|
||||
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<Sh> {
|
||||
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<f32> = 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<Sh> {
|
||||
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<Sh> {
|
||||
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<Sh> {
|
||||
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<Sh> {
|
||||
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<Sh> {
|
||||
(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<u32>) {
|
||||
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<SmartArtSpec> {
|
||||
.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::<Option<Vec<_>>>()?;
|
||||
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("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
|
||||
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
|
||||
assert!(!plain.contains("srgbClr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagram_with_more_than_the_model_stays_read_only() {
|
||||
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
|
||||
|
||||
@@ -84,6 +84,29 @@ pub fn int(s: &str) -> Option<i64> {
|
||||
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<f64> {
|
||||
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::<f64>().ok()? / 65_536.0,
|
||||
None => t.parse::<f64>().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<u32> {
|
||||
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const MAX_STORIES: usize = 50_000;
|
||||
|
||||
/// Read a Word 2003 XML Document.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
crate::read::read_opened(&convert(bytes)?)
|
||||
crate::read::read_opened(&convert(bytes)?, None)
|
||||
}
|
||||
|
||||
/// Parts the converted package gets relationships for.
|
||||
|
||||
@@ -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<Node> {
|
||||
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<Node> = 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))]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
|
||||
ScriptKind, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -181,7 +182,10 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
|
||||
|
||||
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
|
||||
let MNode::Matrix { rows, col_jc } = n else { return };
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
|
||||
// caps rather than written as rows × widest cells (#3643).
|
||||
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -319,6 +326,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
|
||||
a.push(("w:vSpace", &vs));
|
||||
}
|
||||
a.push(("w:wrap", &f.wrap));
|
||||
if let Some(anchor_lock) = f.anchor_lock {
|
||||
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
|
||||
}
|
||||
a.push(("w:vAnchor", &f.v_anchor));
|
||||
a.push(("w:hAnchor", &f.h_anchor));
|
||||
match &f.x_align {
|
||||
@@ -366,6 +376,7 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
|
||||
toggle(w, "w:autoSpaceDE", p.auto_space_de);
|
||||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
|
||||
toggle(w, "w:snapToGrid", p.snap_to_grid);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]))]);
|
||||
@@ -382,11 +396,10 @@ fn line_dash(w: &mut W, dash: &wordcraft_doc::line_dash::LineDash) {
|
||||
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
|
||||
/// A line's arrowheads (`a:headEnd`, `a:tailEnd`, ECMA-376 §20.1.8.38 / §20.1.8.57).
|
||||
fn arrow_ends(w: &mut W, extra: &wordcraft_doc::connector::ShapeExtra) {
|
||||
if extra.arrow_start {
|
||||
w.empty("a:headEnd", &[("type", "triangle")]);
|
||||
}
|
||||
if extra.arrow_end {
|
||||
w.empty("a:tailEnd", &[("type", "triangle")]);
|
||||
for (on, tag, style) in [(extra.arrow_start, "a:headEnd", extra.start_style), (extra.arrow_end, "a:tailEnd", extra.end_style)] {
|
||||
if on {
|
||||
w.empty(tag, &[("type", style.kind.ooxml()), ("w", style.width.ooxml()), ("len", style.length.ooxml())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,9 @@ impl Writer<'_> {
|
||||
// ink keep their original source and related parts.
|
||||
InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth),
|
||||
InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth),
|
||||
InlineObject::Shape { extra, float, .. } if extra.hr.is_some() && float.wrap == Wrap::Inline && !float.hidden => {
|
||||
self.write_hr(w, o, props)
|
||||
}
|
||||
InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth),
|
||||
InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth),
|
||||
InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => {
|
||||
@@ -246,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);
|
||||
@@ -277,6 +280,74 @@ impl Writer<'_> {
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
||||
/// A horizontal rule goes back as the VML rule it was read from: DrawingML has none, and a
|
||||
/// fixed-size shape would lose its line-wide width.
|
||||
fn write_hr(&mut self, w: &mut W, o: &InlineObject, props: &CharProps) {
|
||||
let InlineObject::Shape { w: sw, h: sh, fill, stroke, stroke_width, extra, float, .. } = o else { return };
|
||||
let Some(hr) = extra.hr else { return };
|
||||
let align = match hr.align {
|
||||
wordcraft_doc::props::Align::Center => "center",
|
||||
wordcraft_doc::props::Align::Right => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let width = if hr.pct > 0.0 { "0".to_string() } else { format!("{:.2}pt", sw.max(0.0)) };
|
||||
let mut style = format!("width:{width};height:{:.2}pt", sh.max(0.0));
|
||||
let spin = float.spin();
|
||||
if spin.deg != 0.0 {
|
||||
style.push_str(&format!(";rotation:{}", spin.deg));
|
||||
}
|
||||
match (spin.flip_h, spin.flip_v) {
|
||||
(true, true) => style.push_str(";flip:x y"),
|
||||
(true, false) => style.push_str(";flip:x"),
|
||||
(false, true) => style.push_str(";flip:y"),
|
||||
(false, false) => {}
|
||||
}
|
||||
let pct = format!("{}", (hr.pct * 10.0).round().max(0.0) as u32);
|
||||
let fill_hex = fill.as_ref().map(|f| format!("#{}", f.main_color().hex()));
|
||||
let stroke_hex = stroke.map(|c| format!("#{}", c.hex()));
|
||||
let weight = format!("{stroke_width:.2}pt");
|
||||
let mut attrs: Vec<(&str, &str)> = vec![
|
||||
("xmlns:v", "urn:schemas-microsoft-com:vml"),
|
||||
("xmlns:o", "urn:schemas-microsoft-com:office:office"),
|
||||
("style", &style),
|
||||
("o:hrpct", &pct),
|
||||
("o:hralign", align),
|
||||
("o:hr", "t"),
|
||||
];
|
||||
if hr.standard {
|
||||
attrs.push(("o:hrstd", "t"));
|
||||
}
|
||||
if let Some(f) = &fill_hex {
|
||||
attrs.push(("fillcolor", f));
|
||||
} else {
|
||||
attrs.push(("filled", "f"));
|
||||
}
|
||||
match &stroke_hex {
|
||||
Some(s) => {
|
||||
attrs.push(("strokecolor", s));
|
||||
attrs.push(("strokeweight", &weight));
|
||||
}
|
||||
None => attrs.push(("stroked", "f")),
|
||||
}
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:pict", &[]);
|
||||
// A see-through fill keeps its opacity, which `fillcolor` can't say.
|
||||
match fill.as_ref() {
|
||||
Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) if *transparency > 0.0 => {
|
||||
let opacity = format!("{:.4}", (1.0 - transparency / 100.0).clamp(0.0, 1.0));
|
||||
w.open("v:rect", &attrs);
|
||||
w.empty("v:fill", &[("opacity", &opacity)]);
|
||||
w.close("v:rect");
|
||||
}
|
||||
_ => w.empty("v:rect", &attrs),
|
||||
}
|
||||
w.close("w:pict");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
||||
fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return };
|
||||
self.rev_open(w, props);
|
||||
|
||||
@@ -87,6 +87,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
("w:gutter", &twips(s.gutter.max(0.0))),
|
||||
],
|
||||
);
|
||||
paper_source(w, s);
|
||||
if let Some(b) = &s.page_borders {
|
||||
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
|
||||
}
|
||||
@@ -107,14 +108,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:lnNumType", &refs);
|
||||
}
|
||||
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
|
||||
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(st) = s.page_num_start {
|
||||
a.push(("w:start", st.to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
page_number_type(w, s);
|
||||
let c = &s.columns;
|
||||
let count = c.count.clamp(1, 45);
|
||||
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
|
||||
@@ -134,6 +128,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:cols", &refs);
|
||||
}
|
||||
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
|
||||
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
|
||||
}
|
||||
if let Some(on) = s.form_prot {
|
||||
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
@@ -143,6 +140,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
|
||||
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if let Some(on) = s.no_endnote {
|
||||
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
|
||||
}
|
||||
if s.title_page {
|
||||
w.empty("w:titlePg", &[]);
|
||||
}
|
||||
@@ -167,6 +167,43 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
||||
}
|
||||
}
|
||||
|
||||
fn paper_source(w: &mut W, s: &SectionProps) {
|
||||
let mut attrs = Vec::new();
|
||||
if let Some(first) = s.paper_src_first {
|
||||
attrs.push(("w:first", first.to_string()));
|
||||
}
|
||||
if let Some(other) = s.paper_src_other {
|
||||
attrs.push(("w:other", other.to_string()));
|
||||
}
|
||||
if !attrs.is_empty() {
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:paperSrc", &refs);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_number_type(w: &mut W, s: &SectionProps) {
|
||||
if !(s.page_num_set
|
||||
|| s.page_num_start.is_some()
|
||||
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|
||||
|| s.chapter_style.is_some()
|
||||
|| s.chapter_sep.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
|
||||
if let Some(start) = s.page_num_start {
|
||||
attrs.push(("w:start", start.to_string()));
|
||||
}
|
||||
if let Some(style) = s.chapter_style {
|
||||
attrs.push(("w:chapStyle", style.to_string()));
|
||||
}
|
||||
if let Some(separator) = s.chapter_sep {
|
||||
attrs.push(("w:chapSep", separator.ooxml().to_string()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:pgNumType", &refs);
|
||||
}
|
||||
|
||||
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
|
||||
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
|
||||
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user