mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
Compare commits
196
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fee0ef1d | ||
|
|
f691fe9f11 | ||
|
|
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 | ||
|
|
54988ddfda | ||
|
|
4edb16a946 | ||
|
|
7f6d9fbb97 | ||
|
|
d746e11f65 | ||
|
|
d6fb543e31 | ||
|
|
0592a70d7e | ||
|
|
2158d7388f | ||
|
|
67b3bbfdb0 | ||
|
|
eb8d195188 | ||
|
|
fdbc3322b3 | ||
|
|
c73e9e0e87 | ||
|
|
a6ba7321db | ||
|
|
1eff715f10 | ||
|
|
ca2cf4e0fd | ||
|
|
6f6559aacb | ||
|
|
faac2c42bd | ||
|
|
08dd0906ca | ||
|
|
7160f05014 | ||
|
|
6601aeaf09 | ||
|
|
fdbe410704 | ||
|
|
75ee5e6a43 | ||
|
|
48ff99e52c | ||
|
|
e635c8bd96 | ||
|
|
cef4338947 | ||
|
|
a330e4c503 | ||
|
|
5063000781 | ||
|
|
7b25253286 | ||
|
|
ffa309c266 | ||
|
|
8d5485c8bd | ||
|
|
bc74bc23f5 | ||
|
|
7cdc8d85a4 | ||
|
|
90898f454b | ||
|
|
807fe91724 | ||
|
|
be2666da15 | ||
|
|
959770fa13 | ||
|
|
adfcbcbf7c | ||
|
|
8f65b867c0 | ||
|
|
2f08e8bb01 | ||
|
|
3164c33c28 | ||
|
|
6a62c6142f | ||
|
|
48c60a51b1 | ||
|
|
7be387dabc | ||
|
|
5cdf2608bf | ||
|
|
078945e10c | ||
|
|
56188b97f4 | ||
|
|
bc5d9f9fa5 | ||
|
|
e2b439ed17 | ||
|
|
1dd4e64191 | ||
|
|
ab02c85701 | ||
|
|
7848757954 | ||
|
|
ce516b04f6 | ||
|
|
78bd6b2b72 | ||
|
|
693ccff3b2 | ||
|
|
cb7de0227f | ||
|
|
2b826a288c | ||
|
|
2036ad2e99 | ||
|
|
7c9d301541 | ||
|
|
04ff4e3a54 | ||
|
|
2877514d9e | ||
|
|
27b7f34425 | ||
|
|
4c0bb99016 | ||
|
|
2bc5874da0 | ||
|
|
b114209d2b | ||
|
|
0b6b88f414 | ||
|
|
1e9c11fb1c | ||
|
|
3d464f3ebe | ||
|
|
f683e308c6 | ||
|
|
5f7f89c187 | ||
|
|
72e9593de9 | ||
|
|
6dff74990e | ||
|
|
6925818110 | ||
|
|
b87c80cdc7 | ||
|
|
8506d9da54 | ||
|
|
45b852ad4a | ||
|
|
7eee06a1ab | ||
|
|
b36e0b9fe9 | ||
|
|
16f0b6ed25 | ||
|
|
275037932f | ||
|
|
135ce2229d | ||
|
|
3cc5185b1e | ||
|
|
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 |
Generated
+63
-25
@@ -1103,7 +1103,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1520,6 +1520,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1587,6 +1600,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -6752,7 +6785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6787,7 +6820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6800,11 +6833,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6818,7 +6851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6835,7 +6868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6859,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6892,7 +6925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6906,26 +6939,34 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.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.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6933,7 +6974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -6953,7 +6994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6965,7 +7006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6973,7 +7014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -6994,7 +7035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7008,8 +7049,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
@@ -7017,6 +7059,7 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -7039,7 +7082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7058,7 +7101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7221,7 +7264,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7580,11 +7623,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+5
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,10 +24,11 @@ wordcraft-pdf = { path = "crates/pdf" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# 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 = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
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" }
|
||||
|
||||
+15
-6
@@ -11,13 +11,20 @@ _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.
|
||||
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
|
||||
@@ -25,17 +32,19 @@ Language pack, and every proofing language used in the open document.
|
||||
_Avoid_: Authoring languages, preferred languages
|
||||
|
||||
**Default proofing language**:
|
||||
The proofing language new text gets, set with Set As Default; without one, the interface 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,
|
||||
available to install, or not available.
|
||||
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)
|
||||
|
||||
**Your own dictionaries folder**:
|
||||
A folder of Hunspell dictionaries you supply, used before Language packs and the system's.
|
||||
_Avoid_: Dictionaries folder, custom dictionaries
|
||||
**Dictionaries folder**:
|
||||
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
|
||||
comes after it).
|
||||
_Avoid_: Custom dictionaries
|
||||
|
||||
+80
-6
File diff suppressed because one or more lines are too long
@@ -68,6 +68,10 @@ Paper: documents created here, and HTML, Markdown and text files (which give no
|
||||
the system region's paper (A4, or Letter in the US and other Letter regions), read from
|
||||
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
|
||||
same output on every machine.
|
||||
|
||||
OpenDocument (.odt, .ott, .fodt) files fill in what their standard leaves to the application in
|
||||
Microsoft Word's style (WORDCRAFT_ODF=word, the default) or LibreOffice's
|
||||
(WORDCRAFT_ODF=libreoffice); see docs/odf-interpretation.md.
|
||||
";
|
||||
|
||||
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
|
||||
@@ -86,11 +90,18 @@ fn missing_argument() -> Failure {
|
||||
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
|
||||
/// `convert` does not quietly write a copy the password no longer protects.
|
||||
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
|
||||
open_in(path, password, odf_profile()?)
|
||||
}
|
||||
|
||||
/// [`open`] with the OpenDocument style `odf`, which the session keeps: a later `file.open` or
|
||||
/// `file.new` in a `run` script reads files the same way.
|
||||
fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile::OdfProfile) -> Result<Session, String> {
|
||||
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
|
||||
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
|
||||
let region = system_region();
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?;
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion, &odf)?;
|
||||
let mut s = session(doc);
|
||||
s.prefs.odf = odf;
|
||||
s.path = Some(path.into());
|
||||
if encrypted {
|
||||
s.password = password.map(wordcraft_engine::io::Password::new);
|
||||
@@ -172,6 +183,24 @@ fn session(doc: wordcraft_doc::Document) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset
|
||||
/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too).
|
||||
fn odf_profile() -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
|
||||
odf_from(std::env::var("WORDCRAFT_ODF").ok())
|
||||
}
|
||||
|
||||
/// [`odf_profile`] from `WORDCRAFT_ODF`'s value, if set.
|
||||
fn odf_from(var: Option<String>) -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
match var.filter(|v| !v.trim().is_empty()) {
|
||||
None => Ok(OdfProfile::default()),
|
||||
Some(v) => match OdfPreset::from_id(&v).filter(|p| *p != OdfPreset::Custom) {
|
||||
Some(p) => Ok(OdfProfile::of(p)),
|
||||
None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The system region, read as the app reads it (locale variables, else the platform's locale).
|
||||
fn system_region() -> wordcraft_engine::Region {
|
||||
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
|
||||
@@ -504,6 +533,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
// run starts with one open document.
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
// The script's own `file.open`s read OpenDocument files in `WORDCRAFT_ODF`'s style.
|
||||
s.prefs.odf = odf_profile()?;
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -543,3 +543,21 @@ fn script_parse_resolves_paths_against_the_base() {
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
|
||||
/// `WORDCRAFT_ODF` names a display style, and a session opened in one keeps it, so a `run`
|
||||
/// script's later `file.open` reads OpenDocument files the same way.
|
||||
#[test]
|
||||
fn the_odf_style_is_kept_by_the_session() {
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
assert_eq!(odf_from(None), Ok(OdfProfile::default()));
|
||||
assert_eq!(odf_from(Some("libreoffice".into())), Ok(OdfProfile::libreoffice()));
|
||||
assert_eq!(odf_from(Some("word".into())), Ok(OdfProfile::word()));
|
||||
assert!(odf_from(Some("custom".into())).is_err());
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-odf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("a.txt");
|
||||
std::fs::write(&path, "Hello").unwrap();
|
||||
let s = open_in(&path, None, OdfProfile::libreoffice()).unwrap();
|
||||
assert_eq!(s.prefs.odf, OdfProfile::libreoffice());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] =
|
||||
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
|
||||
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "ott", "fodt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
|
||||
mod font_store;
|
||||
@@ -655,6 +655,8 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
Some("odt") => "application/vnd.oasis.opendocument.text",
|
||||
Some("ott") => "application/vnd.oasis.opendocument.text-template",
|
||||
Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml",
|
||||
Some("rtf") => "application/rtf",
|
||||
Some("html") => "text/html",
|
||||
Some("md" | "txt") => "text/plain",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
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};
|
||||
|
||||
|
||||
@@ -213,6 +213,12 @@ 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")]
|
||||
@@ -384,7 +390,6 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
|
||||
(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
ShapeKind::Line => (w, -h),
|
||||
_ => (w, h),
|
||||
};
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
@@ -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)
|
||||
@@ -734,6 +737,52 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Document::objects_mut`] for filling in what an object shows from what it is (a chart's
|
||||
/// drawing from its data), which doesn't change the document: the markup kept as read for
|
||||
/// the paragraphs and tables holding them (see [`crate::kept`]) follows them.
|
||||
pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
|
||||
// The stories (text boxes', notes') as read before, when filling changes one: they stay
|
||||
// as read after it, so the paragraphs showing them keep their markup.
|
||||
let holds = |b: &Arc<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) {
|
||||
|
||||
@@ -1054,7 +1054,7 @@ impl Document {
|
||||
let mut default_style = None;
|
||||
if keep_styles {
|
||||
if src.styles.get("Normal").is_none() {
|
||||
src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
}
|
||||
// Ids given out here are taken too: two styles never get the same new one.
|
||||
let mut given = std::collections::HashSet::new();
|
||||
@@ -1089,7 +1089,7 @@ impl Document {
|
||||
abs_map.insert(a.id, id);
|
||||
let mut a = crate::numbering::AbstractNum { id, ..a.clone() };
|
||||
a.levels.iter_mut().for_each(|l| restyle(&mut l.style));
|
||||
self.numbering.abstracts.push(a);
|
||||
Arc::make_mut(&mut self.numbering).abstracts.push(a);
|
||||
}
|
||||
let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut nums = BTreeMap::new();
|
||||
@@ -1099,7 +1099,7 @@ impl Document {
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() };
|
||||
n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style));
|
||||
self.numbering.nums.push(n);
|
||||
Arc::make_mut(&mut self.numbering).nums.push(n);
|
||||
}
|
||||
let renumber = |n: &mut Option<crate::props::NumRef>| {
|
||||
if let Some(n) = n.as_mut()
|
||||
@@ -1138,7 +1138,7 @@ impl Document {
|
||||
}
|
||||
if self.styles.get(&st.id).is_none() {
|
||||
renumber(&mut st.para.numbering);
|
||||
self.styles.upsert(st);
|
||||
Arc::make_mut(&mut self.styles).upsert(st);
|
||||
}
|
||||
}
|
||||
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
|
||||
|
||||
@@ -663,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)));
|
||||
@@ -690,19 +692,19 @@ fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
|
||||
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
|
||||
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
|
||||
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
|
||||
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
|
||||
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
theirs.styles.upsert(style);
|
||||
Arc::make_mut(&mut theirs.styles).upsert(style);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
p.runs[0].props.del = Some(0);
|
||||
@@ -746,11 +748,11 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
use crate::styles::{Style, StyleKind, StyleSheet};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles = StyleSheet::empty();
|
||||
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
theirs.styles = Arc::new(StyleSheet::empty());
|
||||
Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Big".into());
|
||||
p.runs[0].props.style = Some("Link".into());
|
||||
@@ -764,15 +766,15 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
|
||||
use crate::styles::Style;
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered);
|
||||
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
|
||||
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
a.levels[0].style = Some("Heading1".into());
|
||||
let level = a.levels[0].clone();
|
||||
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
|
||||
let before = ours.numbering.abstracts.len();
|
||||
ours.adopt_with(theirs, true);
|
||||
@@ -812,10 +814,10 @@ fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
|
||||
use crate::{Revision, RevisionKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
// Live and old props name the same styles (ours has them too) and their list.
|
||||
@@ -843,8 +845,8 @@ fn adopted_styles_never_become_our_defaults() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
ours.adopt_with(theirs, keep);
|
||||
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
|
||||
}
|
||||
|
||||
@@ -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,697 @@
|
||||
//! 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 {
|
||||
self.pages_fp.is_some_and(|fp| fp == pages_fingerprint(doc))
|
||||
}
|
||||
/// 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, &replies(doc))
|
||||
}
|
||||
|
||||
/// Each comment's replies, by the comment's id.
|
||||
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -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;
|
||||
@@ -38,6 +39,7 @@ pub mod math_latex;
|
||||
pub mod math_linear;
|
||||
pub mod math_symbols;
|
||||
pub mod numbering;
|
||||
pub mod odf_profile;
|
||||
pub mod para;
|
||||
pub mod props;
|
||||
pub mod resolve;
|
||||
@@ -98,8 +100,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,
|
||||
@@ -127,6 +133,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"] {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! How WordCraft fills in what the OpenDocument standard leaves to the application.
|
||||
//!
|
||||
//! Where ODF 1.3 defines a value (directly, or by referring to XSL for an `fo:` property), that
|
||||
//! is the rule and nothing here changes it. Where it doesn't (ODF 1.3 Part 3 §16.2: "in all
|
||||
//! remaining cases an implementation-dependent value is used"), each application chooses, and
|
||||
//! documents from one application can look different in another. An [`OdfProfile`] is that
|
||||
//! choice for every such case: Microsoft Word's style ([`OdfPreset::Word`], the default: as
|
||||
//! WordCraft lays out Word documents), LibreOffice's style ([`OdfPreset::LibreOffice`]), or the
|
||||
//! user's own ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// Whose choices fill in what the standard leaves open.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OdfPreset {
|
||||
/// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents.
|
||||
/// (`wordcraft`, its earlier id, is still taken.)
|
||||
#[default]
|
||||
#[serde(alias = "wordcraft")]
|
||||
Word,
|
||||
/// LibreOffice's (and Collabora's, which share its engine).
|
||||
LibreOffice,
|
||||
/// The user's own choice for each case ([`OdfProfile`]'s values).
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl OdfPreset {
|
||||
pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
OdfPreset::Word => "word",
|
||||
OdfPreset::LibreOffice => "libreoffice",
|
||||
OdfPreset::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset named `id` (any case): its id, or `wordcraft` / `msword` for Word's.
|
||||
pub fn from_id(id: &str) -> Option<OdfPreset> {
|
||||
let id = id.trim();
|
||||
if ["wordcraft", "msword", "ms-word"].iter().any(|a| a.eq_ignore_ascii_case(id)) {
|
||||
return Some(OdfPreset::Word);
|
||||
}
|
||||
OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// The two styles a document can be shown in, as the user chooses between them (Custom
|
||||
/// aside).
|
||||
pub const STYLES: [OdfPreset; 2] = [OdfPreset::Word, OdfPreset::LibreOffice];
|
||||
|
||||
/// Its name as the user sees it (File › Options, English; the UI translates it).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
OdfPreset::Word => "Microsoft Word style",
|
||||
OdfPreset::LibreOffice => "LibreOffice style",
|
||||
OdfPreset::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most a length in a profile may be, points (22 inches).
|
||||
pub const MAX_LENGTH: f32 = 1584.0;
|
||||
|
||||
/// The choice for each case the standard leaves open (see the module docs). With a preset other
|
||||
/// than [`OdfPreset::Custom`], the preset's values apply whatever these hold
|
||||
/// ([`OdfProfile::effective`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfProfile {
|
||||
pub preset: OdfPreset,
|
||||
/// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins
|
||||
/// (§7.10), whose initial value is 0; whether that initial value comes with the reference
|
||||
/// the standard doesn't say (LibreOffice uses 2 cm).
|
||||
pub page_margin: f32,
|
||||
/// The height of a header or footer whose style states none (§20.212.2 `fo:min-height`
|
||||
/// has no default), points.
|
||||
pub header_min_height: f32,
|
||||
/// A header's or footer's minimum height takes in its spacing from the body (`fo:margin-*`
|
||||
/// of `style:header-footer-properties`; the standard doesn't say), rather than the spacing
|
||||
/// coming after it.
|
||||
pub header_height_includes_spacing: bool,
|
||||
/// What a header or footer whose style doesn't say does with its spacing when its content
|
||||
/// is taller than its height (§20.264 `style:dynamic-spacing` has no default): grows into it
|
||||
/// (`true`), or keeps it (`false`, both presets: with no height stated, growing into it would
|
||||
/// drop the spacing the file states).
|
||||
pub dynamic_spacing: bool,
|
||||
/// Paragraph tab stops are measured from the paragraph's indent rather than the margin when
|
||||
/// the document doesn't say (§19.512.3 `style:position` doesn't say from where; §19.861 `text:relative-tab-stop-position` says so only for indexes; LibreOffice's own
|
||||
/// `TabsRelativeToIndent` setting, in `settings.xml`, says so when present).
|
||||
pub tabs_from_indent: bool,
|
||||
/// The gap between columns whose `style:columns` states neither one nor each column
|
||||
/// (§19.233 `fo:column-gap` has no default), points.
|
||||
pub column_gap: f32,
|
||||
/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no
|
||||
/// default).
|
||||
pub shape_fill: Rgb,
|
||||
/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no default).
|
||||
pub shape_line: Rgb,
|
||||
/// A drawing shape's line width when no style gives one, and what a width of 0 draws at
|
||||
/// (§20.410 `svg:stroke-width` gives neither), points.
|
||||
pub hairline: f32,
|
||||
/// A page-anchored frame placed against a paragraph (a combination §20.398 and §20.299 don't
|
||||
/// define) is placed against the page (`true`), else against the page's text area.
|
||||
pub page_frame_paragraph_is_page: bool,
|
||||
/// An in-line frame placed against something an in-line frame can't be (a paragraph or a
|
||||
/// page, §20.398 Table 23) is placed against the baseline (`true`), else stands on it.
|
||||
pub inline_invalid_relation_is_baseline: bool,
|
||||
/// A line holding only in-line pictures is as tall as they are (`true`), else as tall as
|
||||
/// its text would be too (the standard defines no line-height algorithm).
|
||||
pub picture_lines_fit_pictures: bool,
|
||||
/// A columned section that doesn't say whether its columns are balanced (§20.428
|
||||
/// `text:dont-balance-text-columns` has no default, and its value descriptions contradict
|
||||
/// its name) ends with balanced columns at the end of the document too (`true`), else only
|
||||
/// when text follows on the same page, as in Word.
|
||||
pub balance_section_columns: bool,
|
||||
/// The space between two paragraphs is the first's space after plus the second's space
|
||||
/// before (`true`), else the larger of the two, when the document doesn't say (the
|
||||
/// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present).
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// Widow and orphan control when no style states `fo:widows` or `fo:orphans` (§20.312,
|
||||
/// §20.244 refer to XSL's, whose initial value is 2 lines; whether that initial value comes
|
||||
/// with the reference the standard doesn't say, and LibreOffice reads none stated as none).
|
||||
pub widow_control: bool,
|
||||
}
|
||||
|
||||
impl Default for OdfProfile {
|
||||
fn default() -> Self {
|
||||
OdfProfile::word()
|
||||
}
|
||||
}
|
||||
|
||||
impl OdfProfile {
|
||||
/// Microsoft Word's style: as WordCraft lays out its own and Word documents.
|
||||
pub fn word() -> OdfProfile {
|
||||
OdfProfile {
|
||||
preset: OdfPreset::Word,
|
||||
page_margin: 0.0,
|
||||
header_min_height: 0.0,
|
||||
header_height_includes_spacing: false,
|
||||
dynamic_spacing: false,
|
||||
tabs_from_indent: false,
|
||||
column_gap: 36.0,
|
||||
shape_fill: Rgb(0x15, 0x60, 0x82),
|
||||
shape_line: Rgb(0x0E, 0x40, 0x5A),
|
||||
hairline: 0.25,
|
||||
page_frame_paragraph_is_page: false,
|
||||
inline_invalid_relation_is_baseline: false,
|
||||
picture_lines_fit_pictures: false,
|
||||
balance_section_columns: false,
|
||||
sum_paragraph_spacing: false,
|
||||
widow_control: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// LibreOffice's choices, measured in LibreOffice 24 where it doesn't document them.
|
||||
pub fn libreoffice() -> OdfProfile {
|
||||
OdfProfile {
|
||||
preset: OdfPreset::LibreOffice,
|
||||
page_margin: 2.0 * 72.0 / 2.54,
|
||||
header_min_height: 0.5 * 72.0 / 2.54,
|
||||
header_height_includes_spacing: true,
|
||||
dynamic_spacing: false,
|
||||
tabs_from_indent: true,
|
||||
column_gap: 0.0,
|
||||
shape_fill: Rgb(0x72, 0x9F, 0xCF),
|
||||
shape_line: Rgb(0x34, 0x65, 0xA4),
|
||||
hairline: 0.5,
|
||||
page_frame_paragraph_is_page: true,
|
||||
inline_invalid_relation_is_baseline: true,
|
||||
picture_lines_fit_pictures: true,
|
||||
balance_section_columns: true,
|
||||
sum_paragraph_spacing: true,
|
||||
widow_control: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset's values (a custom profile starts from Word's style).
|
||||
pub fn of(preset: OdfPreset) -> OdfProfile {
|
||||
match preset {
|
||||
OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() },
|
||||
OdfPreset::LibreOffice => OdfProfile::libreoffice(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The values that apply: a preset's own, or, for a custom profile, these (lengths made
|
||||
/// finite and bounded).
|
||||
pub fn effective(&self) -> OdfProfile {
|
||||
let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least };
|
||||
match self.preset {
|
||||
OdfPreset::Custom => OdfProfile {
|
||||
page_margin: length(self.page_margin, 0.0),
|
||||
header_min_height: length(self.header_min_height, 0.0),
|
||||
column_gap: length(self.column_gap, 0.0),
|
||||
hairline: length(self.hairline, 0.05),
|
||||
..self.clone()
|
||||
},
|
||||
p => OdfProfile::of(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the layout needs to know of it for a document opened from ODF.
|
||||
pub fn layout(&self) -> OdfLayout {
|
||||
let e = self.effective();
|
||||
OdfLayout { picture_lines_fit_pictures: e.picture_lines_fit_pictures, tabs_from_indent: e.tabs_from_indent }
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the profile's choices that are made as the document is laid out.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct OdfLayout {
|
||||
/// See [`OdfProfile::picture_lines_fit_pictures`].
|
||||
pub picture_lines_fit_pictures: bool,
|
||||
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
|
||||
/// `TabsRelativeToIndent`, else [`OdfProfile::tabs_from_indent`]). 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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn presets_and_custom_values() {
|
||||
assert_eq!(OdfProfile::default().preset, OdfPreset::Word);
|
||||
assert_eq!(OdfPreset::from_id(" LibreOffice "), Some(OdfPreset::LibreOffice));
|
||||
assert_eq!(OdfPreset::from_id("google"), None);
|
||||
// Word's style by its id, and by the id it had before.
|
||||
for id in ["word", "Word", "wordcraft", "MSWord", "ms-word"] {
|
||||
assert_eq!(OdfPreset::from_id(id), Some(OdfPreset::Word), "{id}");
|
||||
}
|
||||
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("wordcraft")).unwrap(), OdfPreset::Word);
|
||||
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("word")).unwrap(), OdfPreset::Word);
|
||||
assert_eq!(OdfPreset::STYLES.map(OdfPreset::label), ["Microsoft Word style", "LibreOffice style"]);
|
||||
// Saved and taken by `file.options` as its id.
|
||||
for p in OdfPreset::ALL {
|
||||
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id()));
|
||||
}
|
||||
// A preset's values apply whatever the profile holds.
|
||||
let lo = OdfProfile { column_gap: 99.0, ..OdfProfile::libreoffice() };
|
||||
assert_eq!(lo.effective(), OdfProfile::libreoffice());
|
||||
// A custom profile's own, bounded.
|
||||
let custom = OdfProfile { preset: OdfPreset::Custom, column_gap: f32::NAN, header_min_height: 1e9, ..OdfProfile::word() };
|
||||
let e = custom.effective();
|
||||
assert_eq!((e.column_gap, e.header_min_height), (0.0, MAX_LENGTH));
|
||||
assert!(OdfProfile::libreoffice().layout().picture_lines_fit_pictures);
|
||||
assert!(!OdfProfile::word().layout().picture_lines_fit_pictures);
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,13 @@ 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 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) {
|
||||
|
||||
@@ -183,6 +183,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() {
|
||||
|
||||
@@ -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),
|
||||
@@ -178,6 +182,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`).
|
||||
@@ -265,6 +273,10 @@ impl ResolvedPara {
|
||||
tabs,
|
||||
shading: p.shading,
|
||||
borders: p.borders.filter(Borders::any_visible),
|
||||
padding: p.borders.map_or([0.0; 4], |b| {
|
||||
[b.top, b.left, b.bottom, b.right]
|
||||
.map(|s| s.filter(|l| !l.is_visible() && l.space.is_finite() && l.space > 0.0).map_or(0.0, |l| l.space.min(1584.0)))
|
||||
}),
|
||||
bidi: p.bidi.unwrap_or(false),
|
||||
drop_cap: p.drop_cap.unwrap_or(0).min(10),
|
||||
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
|
||||
@@ -323,6 +335,38 @@ impl StyleSheet {
|
||||
(pp, cp)
|
||||
}
|
||||
|
||||
/// What a table style's paragraph formatting `table` changes for a paragraph with `p`, once
|
||||
/// the paragraph style chain is applied over it (ECMA-376 §17.7.2): each property it gives
|
||||
/// that the chain doesn't set. Formats without table styles state these directly.
|
||||
pub fn table_para_patch(&self, p: &ParaProps, table: &ParaProps) -> ParaProps {
|
||||
let style = p.style.clone().unwrap_or_else(|| "Normal".into());
|
||||
let mut with = self.default_para.clone();
|
||||
with.overlay(&ParaProps { style: None, ..table.clone() });
|
||||
let (with, _) = self.para_style_props_over(&style, with, CharProps::default());
|
||||
let (without, _) = self.para_style_props_over(&style, self.default_para.clone(), CharProps::default());
|
||||
diff_patch(&with, &without)
|
||||
}
|
||||
|
||||
/// What a table style's run formatting `table` changes for a run of character style
|
||||
/// `run_style` in a paragraph of style `para_style`, once the paragraph and character style
|
||||
/// chains are applied over it (ECMA-376 §17.7.2).
|
||||
pub fn table_char_patch(&self, para_style: Option<&str>, run_style: Option<&str>, table: &CharProps) -> CharProps {
|
||||
let chain = |defaults: CharProps| {
|
||||
let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults);
|
||||
if let Some(cs) = run_style {
|
||||
for s in self.chain(cs) {
|
||||
if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph {
|
||||
cp.overlay(&s.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
cp
|
||||
};
|
||||
let mut with = self.default_chr.clone();
|
||||
with.overlay(&CharProps { style: None, ..table.clone() });
|
||||
diff_patch(&chain(with), &chain(self.default_chr.clone()))
|
||||
}
|
||||
|
||||
/// Resolve a paragraph's properties.
|
||||
pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara {
|
||||
self.resolve_para_in(p, None)
|
||||
@@ -387,6 +431,16 @@ impl StyleSheet {
|
||||
}
|
||||
}
|
||||
|
||||
/// The properties of `with` that differ from `without` (both fully resolved property sets of one
|
||||
/// kind), as a set of only those; empty when serialisation fails.
|
||||
fn diff_patch<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 +578,15 @@ mod tests {
|
||||
assert_eq!(p.tabs.len(), 3);
|
||||
assert_eq!(p.tabs[0].pos, 100.0);
|
||||
}
|
||||
|
||||
/// Resolved formatting saved before paragraph padding existed still reads: no padding.
|
||||
#[test]
|
||||
fn resolved_para_without_padding_reads() {
|
||||
let p = StyleSheet::builtin().resolve_para(&ParaProps::default());
|
||||
let mut v = serde_json::to_value(&p).unwrap();
|
||||
v.as_object_mut().unwrap().remove("padding");
|
||||
let back: ResolvedPara = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.padding, [0.0; 4]);
|
||||
assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +733,13 @@ pub struct SectionProps {
|
||||
/// Distance from the page edge to the header / footer.
|
||||
pub header: f32,
|
||||
pub footer: f32,
|
||||
/// The space kept between a header and the body, and between the body and a footer, points
|
||||
/// (ODF's header and footer spacing; Word keeps none). The margin already holds the band and
|
||||
/// its spacing, so it only moves the body once content and spacing reach past the margin.
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub header_gap: f32,
|
||||
#[serde(skip_serializing_if = "is_zero")]
|
||||
pub footer_gap: f32,
|
||||
pub gutter: f32,
|
||||
/// Mirror margins (inside/outside) — set from the document settings.
|
||||
pub columns: Columns,
|
||||
@@ -772,6 +779,11 @@ pub struct SectionProps {
|
||||
pub footnote_pr: NoteProps,
|
||||
#[serde(skip_serializing_if = "NoteProps::is_empty")]
|
||||
pub endnote_pr: NoteProps,
|
||||
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
|
||||
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
|
||||
/// the end of the document, `Some(false)` never.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub balance_columns: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for SectionProps {
|
||||
@@ -788,6 +800,8 @@ impl Default for SectionProps {
|
||||
margin_right: 72.0,
|
||||
header: 36.0,
|
||||
footer: 36.0,
|
||||
header_gap: 0.0,
|
||||
footer_gap: 0.0,
|
||||
gutter: 0.0,
|
||||
columns: Columns::default(),
|
||||
headers: HeaderSet::default(),
|
||||
@@ -807,10 +821,15 @@ impl Default for SectionProps {
|
||||
doc_grid: None,
|
||||
footnote_pr: NoteProps::default(),
|
||||
endnote_pr: NoteProps::default(),
|
||||
balance_columns: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &f32) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
|
||||
!d.is_turned()
|
||||
}
|
||||
|
||||
@@ -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,11 @@ 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, with the choices it leaves open made as the user's profile made them
|
||||
/// (see [`crate::odf_profile`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub odf: Option<crate::odf_profile::OdfLayout>,
|
||||
}
|
||||
|
||||
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
|
||||
@@ -307,6 +316,7 @@ impl Default for Settings {
|
||||
zoom: None,
|
||||
remove_personal_info: false,
|
||||
remove_date_time: false,
|
||||
odf: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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),
|
||||
|
||||
+12
-11
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
@@ -35,8 +36,8 @@ fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let bul_id = d.numbering.add_list(ListKind::Bullet);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let bul_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
@@ -60,9 +61,9 @@ fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
@@ -81,9 +82,9 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
#[test]
|
||||
fn word_count_keeps_a_cjk_list_label_one_word() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].format = section::NumFormat::ChineseCounting;
|
||||
a.levels[0].text = "第%1章".into();
|
||||
a.levels[0].start = 12;
|
||||
@@ -187,7 +188,7 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() {
|
||||
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
|
||||
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
|
||||
|
||||
let n = &mut d.numbering;
|
||||
let n = Arc::make_mut(&mut d.numbering);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.abstracts[1].id = u32::MAX;
|
||||
@@ -229,11 +230,11 @@ fn same_content_sees_every_change() {
|
||||
assert!(!d.same_content(&e), "the formatting changed");
|
||||
// The styles changed.
|
||||
let mut e = d.clone();
|
||||
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
assert!(!d.same_content(&e), "a style changed");
|
||||
// The lists changed.
|
||||
let mut e = d.clone();
|
||||
e.numbering.add_list(ListKind::Numbered);
|
||||
Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered);
|
||||
assert!(!d.same_content(&e), "a list was added");
|
||||
// The settings changed.
|
||||
let mut e = d.clone();
|
||||
@@ -452,9 +453,9 @@ fn box_para(story: u32, id: u32) -> Blocks {
|
||||
fn adopting_relinks_lists_in_deeply_nested_tables() {
|
||||
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
|
||||
let mut ours = Document::from_text("ours");
|
||||
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet);
|
||||
let mut theirs = Document::from_text("theirs");
|
||||
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let mut p = Paragraph::with_text("item", CharProps::default());
|
||||
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
|
||||
|
||||
@@ -109,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,
|
||||
@@ -125,15 +126,15 @@ fn brc_style(t: u8) -> BorderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
|
||||
/// 1-point steps for page borders, which never reach a paragraph property).
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz`
|
||||
/// reads (1-point steps for page borders, which never reach a paragraph property).
|
||||
fn brc_width(t: u8, dpt: u8) -> f32 {
|
||||
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
|
||||
if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) }
|
||||
}
|
||||
|
||||
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
|
||||
/// truncated operand is ignored.
|
||||
fn brc80(op: &[u8]) -> Option<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());
|
||||
|
||||
@@ -25,7 +25,9 @@ mod stsh;
|
||||
mod table;
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::link::hyperlink_target;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::para::Paragraph;
|
||||
@@ -248,9 +250,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.core = core;
|
||||
}
|
||||
doc.body.clear();
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts, standard_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,
|
||||
@@ -722,85 +724,6 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
|
||||
/// (`#bookmark` for internal links); `None` when not a hyperlink.
|
||||
fn hyperlink_target(instr: &str) -> Option<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,
|
||||
}
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
fn pic_location(chpx: &[u8]) -> Option<u32> {
|
||||
for prl in sprm::iter(chpx) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -458,32 +458,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.
|
||||
|
||||
@@ -1209,6 +1209,53 @@ fn table_merges_borders_shading() {
|
||||
assert_eq!(top.width, 0.5);
|
||||
}
|
||||
|
||||
/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border.
|
||||
#[test]
|
||||
fn table_borders_read_border_types_as_paragraphs_do() {
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
let text = "a\u{7}\u{7}tail\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let row_end = {
|
||||
let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat();
|
||||
v.extend_from_slice(&0xD605u16.to_le_bytes());
|
||||
v.push(0x18);
|
||||
// Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero.
|
||||
for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] {
|
||||
v.extend_from_slice(&brc);
|
||||
}
|
||||
v
|
||||
};
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
|
||||
let b = tbl.props.borders.expect("table borders");
|
||||
let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width)));
|
||||
let want = [
|
||||
(S::Dotted, true, 1.0),
|
||||
(S::Triple, true, 1.0),
|
||||
(S::Wave, true, 1.0),
|
||||
(S::None, false, 0.0),
|
||||
(S::Single, true, 12.0),
|
||||
(S::None, false, 0.25),
|
||||
];
|
||||
assert_eq!(seen, want.map(Some));
|
||||
// A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are.
|
||||
let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph");
|
||||
assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// D3: lists (PlfLst + PlfLfo) and the combined document.
|
||||
|
||||
@@ -1469,6 +1516,16 @@ fn hyperlink_target_needs_the_whole_field_name() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
|
||||
}
|
||||
|
||||
/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX.
|
||||
#[test]
|
||||
fn hyperlink_target_keeps_the_address_after_a_bare_switch() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_become_objects() {
|
||||
let text = "Hello world\r";
|
||||
@@ -2482,17 +2539,21 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
|
||||
}
|
||||
|
||||
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick
|
||||
/// border (type 0x02) stays thick in both, as tables read it too.
|
||||
#[test]
|
||||
fn paragraph_borders_read_from_both_forms() {
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
|
||||
let thick80 = [8u8, 0x02, 0x06, 0x01];
|
||||
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
|
||||
let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00];
|
||||
let text = "boxed\r\r";
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
papx_entries: vec![
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc)])),
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])),
|
||||
],
|
||||
..structured_spec(text, text.chars().count() as u32)
|
||||
};
|
||||
@@ -2500,13 +2561,16 @@ fn paragraph_borders_read_from_both_forms() {
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
|
||||
let b = p0.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
|
||||
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick");
|
||||
assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
|
||||
let top = p1.props.borders.expect("borders").top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
|
||||
let b = p1.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick");
|
||||
}
|
||||
|
||||
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -172,8 +172,9 @@ impl PropCtx {
|
||||
}
|
||||
|
||||
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
|
||||
/// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as
|
||||
/// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one.
|
||||
/// scheme mapping, with its `tint` or else its `shade` (hex bytes) applied to the HSL
|
||||
/// luminance as Word does: given both, Word applies the tint alone (MS-OI29500 17.3.2.6 b,
|
||||
/// 17.4.32 a). When given it supersedes the fixed colour; `None` leaves the fixed one.
|
||||
fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option<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 +200,7 @@ impl PropCtx {
|
||||
let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0);
|
||||
if let Some(t) = byte(tint) {
|
||||
c = adjust_lum(c, |l| l * t + (1.0 - t));
|
||||
}
|
||||
if let Some(s) = byte(shade) {
|
||||
} else if let Some(s) = byte(shade) {
|
||||
c = adjust_lum(c, |l| l * s);
|
||||
}
|
||||
Some(c)
|
||||
@@ -208,6 +208,10 @@ impl PropCtx {
|
||||
|
||||
/// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour.
|
||||
pub fn shd_fill(&self, e: &El) -> Option<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 +254,7 @@ impl PropCtx {
|
||||
"w:cs" => c.cs = Some(on_off(k)),
|
||||
"w:caps" => c.caps = Some(on_off(k)),
|
||||
"w:smallCaps" => c.small_caps = Some(on_off(k)),
|
||||
"w:em" => c.emphasis = Some(k.attr("w:val").map(wordcraft_doc::props::EmphasisMark::from_ooxml).unwrap_or_default()),
|
||||
"w:strike" => c.strike = Some(on_off(k)),
|
||||
"w:dstrike" => c.double_strike = Some(on_off(k)),
|
||||
"w:outline" => c.outline = Some(on_off(k)),
|
||||
@@ -282,7 +287,7 @@ impl PropCtx {
|
||||
c.underline_color =
|
||||
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
|
||||
}
|
||||
"w:bdr" => c.border = Some(border(k)),
|
||||
"w:bdr" => c.border = Some(self.border(k)),
|
||||
"w:shd" => c.shading = self.shd_fill(k),
|
||||
"w:vertAlign" => {
|
||||
c.vert_align = k.attr("w:val").map(|v| match v {
|
||||
@@ -361,7 +366,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 +481,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 +540,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 +646,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 +683,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() {
|
||||
@@ -780,7 +788,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) {
|
||||
|
||||
@@ -289,10 +289,10 @@ impl Reader<'_> {
|
||||
}
|
||||
|
||||
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
|
||||
let (mut sp, refs) = sectpr(s);
|
||||
let (mut sp, refs) = sectpr(&self.pc, s);
|
||||
// §17.13.5.32: the section's properties before a tracked change (no header references).
|
||||
if let Some(ch) = s.child("w:sectPrChange") {
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default();
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(&self.pc, o).0).unwrap_or_default();
|
||||
sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
for r in refs {
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -249,7 +249,7 @@ impl Writer<'_> {
|
||||
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
|
||||
// An OLE object read from a file: the object itself, at its current size.
|
||||
if let Some(src) = ole.as_deref()
|
||||
if let Some(src) = ole.as_deref().filter(|e| !e.is_odf_object())
|
||||
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
|
||||
{
|
||||
self.rev_open(w, props);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! in a right-to-left paragraph.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
@@ -189,6 +190,6 @@ fn a_language_the_run_already_has_is_kept() {
|
||||
#[test]
|
||||
fn an_inherited_bidi_language_is_not_repeated() {
|
||||
let mut d = doc_of("سلام WordCraft دنیا", true, arial());
|
||||
d.styles.default_chr.lang_bidi = Some("fa-IR".into());
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang_bidi = Some("fa-IR".into());
|
||||
assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
|
||||
//! the reader reads an omitted size as 10 pt.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
|
||||
fn resolved(d: &Document) -> f32 {
|
||||
@@ -19,7 +20,7 @@ fn defaults(xml: &str) -> &str {
|
||||
#[test]
|
||||
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = None;
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = None;
|
||||
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
|
||||
@@ -33,7 +34,7 @@ fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
|
||||
fn an_explicit_default_size_is_written_as_it_is() {
|
||||
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = Some(size);
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = Some(size);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
|
||||
}
|
||||
@@ -59,8 +60,8 @@ fn an_unset_default_size_does_not_take_a_styles_size() {
|
||||
// The written default is the resolver's own fallback, not what the Normal style resolves to,
|
||||
// so a style that sets a size keeps it and one that does not still falls back to the default.
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = None;
|
||||
if let Some(normal) = d.styles.get_mut("Normal") {
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = None;
|
||||
if let Some(normal) = Arc::make_mut(&mut d.styles).get_mut("Normal") {
|
||||
normal.chr.size = Some(14.0);
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Emphasis marks (`w:em`, ISO/IEC 29500-1 §17.3.2.12: Font › Emphasis mark) are read and
|
||||
//! saved again, in their place in `w:rPr`. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::EmphasisMark;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, xml) in [("_rels/.rels", root.to_string()), ("word/document.xml", doc)] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn marks(d: &wordcraft_doc::Document) -> Vec<Option<EmphasisMark>> {
|
||||
d.body[0].as_para().unwrap().runs.iter().map(|r| r.props.emphasis).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_marks_are_read_and_saved() {
|
||||
let run = |t: &str, em: &str| format!(r#"<w:r><w:rPr><w:cs/>{em}<w:lang w:val="ja-JP"/></w:rPr><w:t>{t}</w:t></w:r>"#);
|
||||
let body = format!(
|
||||
"<w:p>{}{}{}{}{}{}</w:p>",
|
||||
run("a", r#"<w:em w:val="dot"/>"#),
|
||||
run("b", r#"<w:em w:val="comma"/>"#),
|
||||
run("c", r#"<w:em w:val="circle"/>"#),
|
||||
run("d", r#"<w:em w:val="underDot"/>"#),
|
||||
run("e", r#"<w:em w:val="none"/>"#),
|
||||
run("f", r#"<w:em w:val="sparkle"/>"#),
|
||||
);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
// An unknown mark is none (and the run with it joins the one before, now alike).
|
||||
let want = [EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot, EmphasisMark::None].map(Some);
|
||||
assert_eq!(marks(&d), want);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
// In its place: after w:cs, before w:lang.
|
||||
assert!(xml.contains(r#"<w:cs/><w:em w:val="underDot"/><w:lang"#), "{xml}");
|
||||
assert_eq!(marks(&wordcraft_docx::read(&saved).unwrap()), want);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
@@ -252,7 +253,7 @@ fn omitted_paragraph_defaults_can_be_edited() {
|
||||
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
|
||||
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
assert!(!d.styles.default_para_present);
|
||||
d.styles.default_para.space_after = Some(12.0);
|
||||
Arc::make_mut(&mut d.styles).default_para.space_after = Some(12.0);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert!(again.styles.default_para_present);
|
||||
assert_eq!(again.styles.default_para.space_after, Some(12.0));
|
||||
@@ -2169,6 +2170,68 @@ fn theme_colour_wins_over_the_fixed_colour() {
|
||||
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
|
||||
}
|
||||
|
||||
/// `w:shd w:val="nil"` paints nothing despite its fill: a CV template's red cell showed over the name.
|
||||
#[test]
|
||||
fn nil_shading_paints_no_fill() {
|
||||
let body = r#"
|
||||
<w:tbl><w:tblGrid><w:gridCol w:w="200"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr>
|
||||
<w:tc><w:tcPr><w:shd w:val="nil" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc>
|
||||
<w:tc><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
|
||||
<w:p><w:pPr><w:shd w:val="nil" w:fill="C00000" w:themeFill="accent2"/></w:pPr><w:r><w:rPr><w:shd w:val="nil" w:color="C00000" w:fill="C00000"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let Block::Table(t) = &*d.body[0] else { panic!("table first") };
|
||||
assert_eq!(t.rows[0].cells[0].props.shading, None);
|
||||
assert_eq!(t.rows[0].cells[1].props.shading, Some(Rgb(0xC0, 0, 0)));
|
||||
let p = paras(&d);
|
||||
assert_eq!(p[0].props.shading, None);
|
||||
assert_eq!(p[0].props_of_char(0).shading, None);
|
||||
}
|
||||
|
||||
/// A border's theme colour wins over its stale `w:color`: a template's white rule showed as black.
|
||||
#[test]
|
||||
fn border_theme_colour_wins_over_the_fixed_colour() {
|
||||
let body = r#"
|
||||
<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="background1"/><w:left w:val="single" w:sz="4" w:color="FF0000"/></w:pBdr></w:pPr><w:r><w:rPr><w:bdr w:val="single" w:sz="4" w:color="auto" w:themeColor="accent2"/></w:rPr><w:t>x</w:t></w:r></w:p>
|
||||
<w:tbl><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="accent1"/></w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcBorders><w:left w:val="single" w:sz="4" w:color="000000" w:themeColor="accent6" w:themeShade="BF"/></w:tcBorders></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
|
||||
<w:p/>
|
||||
<w:sectPr><w:pgBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="text2"/></w:pgBorders></w:sectPr>"#;
|
||||
let styles = r#"<w:style w:type="table" w:styleId="T"><w:name w:val="T"/><w:tblPr><w:tblBorders><w:bottom w:val="single" w:sz="4" w:color="000000" w:themeColor="accent3"/></w:tblBorders></w:tblPr></w:style>"#;
|
||||
let d = themed_docx(body, styles, None);
|
||||
let p = paras(&d);
|
||||
let pb = p[0].props.borders.unwrap();
|
||||
assert_eq!(pb.top.and_then(|b| b.color), Some(Rgb(0xFF, 0xFF, 0xFF)));
|
||||
assert_eq!(pb.left.and_then(|b| b.color), Some(Rgb(0xFF, 0, 0)), "no theme colour keeps the fixed one");
|
||||
assert_eq!(p[0].props_of_char(0).border.and_then(|b| b.color), Some(Rgb(0xED, 0x7D, 0x31)));
|
||||
let Block::Table(t) = &*d.body[1] else { panic!("table second") };
|
||||
assert_eq!(t.props.borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x72, 0xC4)));
|
||||
let cell = t.rows[0].cells[0].props.borders.and_then(|b| b.left).and_then(|b| b.color);
|
||||
assert!(near(cell.map(TextColor::Rgb), Rgb(0x53, 0x81, 0x35)), "{cell:?}");
|
||||
assert_eq!(d.last_section.page_borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x54, 0x6A)));
|
||||
let st = d.styles.get("T").and_then(|s| s.table.as_ref()).and_then(|t| t.borders);
|
||||
assert_eq!(st.and_then(|b| b.bottom).and_then(|b| b.color), Some(Rgb(0xA5, 0xA5, 0xA5)));
|
||||
}
|
||||
|
||||
/// Given both `themeTint` and `themeShade`, Word applies the tint alone, for text colours,
|
||||
/// shading and borders (MS-OI29500 17.3.2.6 b, 17.4.32 a; MS-OE376 2.3.2.3 a).
|
||||
#[test]
|
||||
fn theme_tint_wins_over_theme_shade() {
|
||||
let both = r#"w:themeColor="accent1" w:themeTint="99" w:themeShade="7F""#;
|
||||
let body = format!(
|
||||
r#"<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" {both}/></w:pBdr></w:pPr><w:r><w:rPr><w:color w:val="000000" {both}/><w:bdr w:val="single" w:sz="4" w:color="000000" {both}/><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent1" w:themeFillTint="99" w:themeFillShade="7F"/></w:rPr><w:t>a</w:t></w:r></w:p>"#
|
||||
);
|
||||
let d = themed_docx(&body, "", None);
|
||||
let p = paras(&d);
|
||||
let ch = p[0].props_of_char(0);
|
||||
// accent1 4472C4 lightened by tint 99 alone, as Word writes it into w:val.
|
||||
let tinted = Rgb(0x8E, 0xAA, 0xDB);
|
||||
assert!(near(ch.color, tinted), "text colour {:?}", ch.color);
|
||||
let run_border = ch.border.and_then(|b| b.color).map(TextColor::Rgb);
|
||||
assert!(near(run_border, tinted), "text border {run_border:?}");
|
||||
let para_border = p[0].props.borders.and_then(|b| b.top).and_then(|b| b.color).map(TextColor::Rgb);
|
||||
assert!(near(para_border, tinted), "paragraph border {para_border:?}");
|
||||
assert!(near(ch.shading.map(TextColor::Rgb), tinted), "shading {:?}", ch.shading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_colours_follow_the_colour_scheme_mapping() {
|
||||
// Styles are read before settings; the mapping still applies to them.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block};
|
||||
|
||||
@@ -191,7 +192,7 @@ fn glossary_entries_are_read_into_the_model() {
|
||||
fn edited_glossary_is_written_from_the_model() {
|
||||
let mut doc = wordcraft_docx::read(&with_glossary()).unwrap();
|
||||
doc.glossary[0].name = "Sign-off".into();
|
||||
let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let num = Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let mut listed = Paragraph::with_text("Second line", CharProps::default());
|
||||
listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
doc.glossary.push(BuildingBlock {
|
||||
|
||||
@@ -13,6 +13,7 @@ mod default_size;
|
||||
mod drawing_payload;
|
||||
mod east_asian_lang;
|
||||
mod east_asian_layout;
|
||||
mod emphasis_marks;
|
||||
mod fixtures;
|
||||
mod glossary;
|
||||
mod heading_levels;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
@@ -272,7 +273,7 @@ fn comment_references_use_the_documents_annotation_reference_style() {
|
||||
assert!(!body.contains(r#"w:val="CommentReference""#), "{body}");
|
||||
// A document without that style keeps the English id.
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
|
||||
Arc::make_mut(&mut doc.styles).styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
|
||||
let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap();
|
||||
doc.body = rebuilt.body.clone();
|
||||
doc.parts = rebuilt.parts.clone();
|
||||
@@ -314,7 +315,7 @@ fn list_identities_survive_and_a_copied_definition_gets_none() {
|
||||
// A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid.
|
||||
let mut copy = doc.numbering.abstracts[0].clone();
|
||||
copy.id = 7;
|
||||
doc.numbering.abstracts.push(copy);
|
||||
Arc::make_mut(&mut doc.numbering).abstracts.push(copy);
|
||||
let n = text(&save(&doc), "word/numbering.xml");
|
||||
assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}");
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ fn every_char_prop_round_trips() {
|
||||
scale: Some(150.0),
|
||||
position: Some(3.0),
|
||||
kern: Some(8.0),
|
||||
emphasis: Some(wordcraft_doc::props::EmphasisMark::Comma),
|
||||
ligatures: Some(wordcraft_doc::props::Ligatures { standard: true, historical: true, ..Default::default() }),
|
||||
outline: Some(true),
|
||||
shadow: Some(true),
|
||||
@@ -248,7 +249,7 @@ fn every_para_prop_round_trips() {
|
||||
#[test]
|
||||
fn styles_and_lists_round_trip() {
|
||||
let mut d = Document::new();
|
||||
d.styles.upsert(Style {
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "MyStyle".into(),
|
||||
name: "My Style".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -265,7 +266,7 @@ fn styles_and_lists_round_trip() {
|
||||
default: false,
|
||||
table: None,
|
||||
});
|
||||
d.styles.upsert(Style {
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "MyStyleChar".into(),
|
||||
name: "My Style Char".into(),
|
||||
kind: StyleKind::Character,
|
||||
@@ -274,10 +275,10 @@ fn styles_and_lists_round_trip() {
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
});
|
||||
let bullets = d.numbering.add_list(ListKind::Bullet);
|
||||
let nums = d.numbering.add_list(ListKind::Legal);
|
||||
let outline = d.numbering.add_list(ListKind::Outline);
|
||||
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == outline) {
|
||||
let bullets = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
|
||||
let nums = Arc::make_mut(&mut d.numbering).add_list(ListKind::Legal);
|
||||
let outline = Arc::make_mut(&mut d.numbering).add_list(ListKind::Outline);
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == outline) {
|
||||
n.start_overrides.push((0, 4));
|
||||
}
|
||||
let mut ps = Vec::new();
|
||||
@@ -567,7 +568,7 @@ fn table_style_formatting_round_trips() {
|
||||
band_size: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
d.styles.upsert(Style {
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "Whole".into(),
|
||||
name: "Whole".into(),
|
||||
kind: StyleKind::Table,
|
||||
@@ -620,7 +621,7 @@ fn every_table_style_part_round_trips() {
|
||||
};
|
||||
st.set_table_part(part, f);
|
||||
}
|
||||
d.styles.upsert(st.clone());
|
||||
Arc::make_mut(&mut d.styles).upsert(st.clone());
|
||||
let r = rt(&d);
|
||||
let got = r.styles.get("Parts").unwrap();
|
||||
for part in TablePart::ALL {
|
||||
@@ -1708,7 +1709,7 @@ fn kitchen_sink() -> Document {
|
||||
let mut d = Document::new();
|
||||
let png = tiny_png();
|
||||
let key = d.add_media(png, "png");
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
|
||||
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
|
||||
@@ -1940,10 +1941,10 @@ fn list_level_overrides_round_trip() {
|
||||
use wordcraft_doc::numbering::{Counters, Level};
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let restart = d.numbering.restart(num).unwrap();
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let restart = Arc::make_mut(&mut d.numbering).restart(num).unwrap();
|
||||
let own = Level { format: NumFormat::DecimalZero, text: "%1.%2".into(), indent: 26.5, hanging: 26.5, ..Level::default() };
|
||||
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == restart) {
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == restart) {
|
||||
n.level_overrides = vec![(1, own.clone())];
|
||||
}
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -32,7 +32,7 @@ fn sample() -> Document {
|
||||
d.core.title = "Garden <notes> & more".into();
|
||||
d.core.creator = "Ada".into();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
|
||||
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
|
||||
|
||||
@@ -70,6 +70,7 @@ Home|Font|Character Shading|format.shading
|
||||
Home|Font|Font Dialog|format.fontDialog
|
||||
Home|Font|Double Strikethrough|format.doubleStrikethrough
|
||||
Home|Font|Small Caps|format.smallCaps
|
||||
Home|Font|Emphasis Mark|format.emphasisMark
|
||||
Home|Font|All Caps|format.allCaps
|
||||
Home|Font|Hidden|format.hidden
|
||||
Home|Font|Emboss|format.emboss
|
||||
|
||||
@@ -194,12 +194,15 @@ fn edit(s: &mut Session, f: impl Fn(&mut ChartSpec) -> Result<(), CmdError>) ->
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Draw the chart of `o` again at its current size (after a resize), if it has a model.
|
||||
/// Draw the chart of `o` again at its current size (after a resize, or when it opens), if it
|
||||
/// has a model. It keeps the chart as read (its `source`): the chart itself is unchanged, and
|
||||
/// saving writes that back with the look WordCraft doesn't model.
|
||||
pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) {
|
||||
if let InlineObject::Graphic { w, h, graphic, .. } = o
|
||||
&& let Some(spec) = graphic.chart.as_deref()
|
||||
{
|
||||
*graphic = Arc::new(chart_graphic(spec.clone(), theme, *w, *h));
|
||||
let source = graphic.source.clone();
|
||||
*graphic = Arc::new(Graphic { source, ..chart_graphic(spec.clone(), theme, *w, *h) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
|
||||
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
|
||||
@@ -65,6 +66,47 @@ fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option
|
||||
update_generated(s)
|
||||
}
|
||||
|
||||
/// Most paragraphs a list held as a field around its entries spans.
|
||||
const MAX_WRAPPED: usize = 10_000;
|
||||
|
||||
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
|
||||
/// field that starts at body block `i` and runs over its entries: its field code and the block
|
||||
/// it ends in. `None` for anything else (a TOC has its own update).
|
||||
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize)> {
|
||||
let p = s.doc.body.get(i)?.as_para()?;
|
||||
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
|
||||
InlineObject::FieldStart { instr, .. } => {
|
||||
let t = instr.trim_start();
|
||||
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some())
|
||||
.then(|| (k, off, t.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
})?;
|
||||
// Only when it starts the paragraph: text before it is the document's, not the list's.
|
||||
if !p.text.get(..off).is_some_and(|t| t.chars().all(|c| c.is_whitespace() || c == wordcraft_doc::para::OBJ)) {
|
||||
return None;
|
||||
}
|
||||
// Where it ends: the field end that closes it, nested fields counted from its own start (a
|
||||
// field before it in its paragraph, an empty `PAGE`…, closes before it and isn't it).
|
||||
let mut depth = 0usize;
|
||||
for (j, b) in s.doc.body.iter().enumerate().skip(i).take(MAX_WRAPPED) {
|
||||
let Some(q) = b.as_para() else { continue };
|
||||
for (at, o) in q.objects_with_offsets().skip(if j == i { k } else { 0 }) {
|
||||
match o {
|
||||
InlineObject::FieldStart { .. } => depth += 1,
|
||||
InlineObject::FieldEnd => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some((instr, j, at + wordcraft_doc::para::OBJ.len_utf8()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn list_kind(p: &Paragraph) -> Option<String> {
|
||||
p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
@@ -152,6 +194,43 @@ pub(super) const ENTRY_STYLE: &str = "ListEntry";
|
||||
pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
let mut i = 0;
|
||||
while i < s.doc.body.len() {
|
||||
// A list held as a field around its entries (as Word and ODT files hold them): made the
|
||||
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
|
||||
// What follows its closing field end, and its first paragraph's properties and section
|
||||
// break, are the document's and stay.
|
||||
if let Some((instr, last, end)) = wrapped_list(s, i) {
|
||||
// The entries between its first and last paragraphs go.
|
||||
for _ in i.saturating_add(1)..last {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
|
||||
}
|
||||
// Its first paragraph keeps only the field; its last (or, ending in its first, the
|
||||
// same) loses what is up to and through the field end, and goes when nothing is left
|
||||
// of it and it ends no section.
|
||||
let first_end = if last > i {
|
||||
let q = s.doc.para_mut(StoryRef::Body, &Path::top(i + 1))?;
|
||||
q.delete(0, end)?;
|
||||
if q.is_empty() && q.section.is_none() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
|
||||
}
|
||||
s.doc.para_mut(StoryRef::Body, &Path::top(i))?.len()
|
||||
} else {
|
||||
end
|
||||
};
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
|
||||
p.delete(0, first_end)?;
|
||||
p.insert_object(
|
||||
0,
|
||||
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&Default::default(),
|
||||
)?;
|
||||
// What followed the field end in its own paragraph comes after the list, in a
|
||||
// paragraph of its own that takes the section break.
|
||||
let field = wordcraft_doc::para::OBJ.len_utf8();
|
||||
if p.len() > field {
|
||||
let rest = p.split_off(field)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
|
||||
}
|
||||
}
|
||||
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
|
||||
let Some(kind) = kind else {
|
||||
i += 1;
|
||||
@@ -222,7 +301,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
/// layout cache starts over (and the saved file would gain a style it doesn't use).
|
||||
fn add_entry_style(s: &mut Session) {
|
||||
if s.doc.styles.get(ENTRY_STYLE).is_none() {
|
||||
s.doc.styles.upsert(wordcraft_doc::Style {
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(wordcraft_doc::Style {
|
||||
id: ENTRY_STYLE.into(),
|
||||
name: "List Entry".into(),
|
||||
kind: wordcraft_doc::StyleKind::Paragraph,
|
||||
@@ -332,3 +411,80 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A field before a list's in its paragraph (an empty `PAGE`) closes first: the list still
|
||||
/// ends at its own field end, paragraphs below, not in its first paragraph.
|
||||
#[test]
|
||||
fn a_wrapped_list_ends_at_its_own_field_end() {
|
||||
let mut d = Document::from_text("Old one\nOld two\nAfter");
|
||||
let props = CharProps::default();
|
||||
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(0)) {
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
|
||||
}
|
||||
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(1)) {
|
||||
let n = p.len();
|
||||
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
|
||||
}
|
||||
let s = Session::new(d);
|
||||
assert_eq!(wrapped_list(&s, 0).map(|(instr, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
|
||||
}
|
||||
|
||||
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
|
||||
/// in paragraph `last` at byte `at`.
|
||||
fn wrapped(text: &str, first: usize, last: usize, at: usize) -> Session {
|
||||
let mut d = Document::from_text(text);
|
||||
let props = CharProps::default();
|
||||
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(last)) {
|
||||
p.insert_object(at, InlineObject::FieldEnd, &props).unwrap();
|
||||
}
|
||||
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(first)) {
|
||||
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
|
||||
}
|
||||
Session::new(d)
|
||||
}
|
||||
|
||||
fn texts(s: &Session) -> Vec<String> {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()
|
||||
}
|
||||
|
||||
/// What follows a wrapped list's closing field end, in its last paragraph or in its only one,
|
||||
/// stays, after the list; the last paragraph's section break stays too.
|
||||
#[test]
|
||||
fn what_follows_a_wrapped_lists_end_stays() {
|
||||
// Ending in a later paragraph, with text after the end and a section break.
|
||||
let mut s = wrapped("Old one\nOld two\nOld three. After\nBelow", 0, 2, "Old three.".len());
|
||||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(2)) {
|
||||
p.section = Some(Box::default());
|
||||
}
|
||||
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
|
||||
p.props.indent_left = Some(9.0);
|
||||
}
|
||||
update_generated(&mut s).unwrap();
|
||||
let t = texts(&s);
|
||||
assert!(t.iter().all(|t| !t.contains("Old")), "{t:?}");
|
||||
assert!(t.iter().any(|t| t == " After"), "{t:?}");
|
||||
assert_eq!(t.last().map(String::as_str), Some("Below"));
|
||||
let after = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.plain_text() == " After");
|
||||
assert!(after.is_some_and(|p| p.section.is_some()), "the section break went");
|
||||
let first = s.doc.body.first().and_then(|b| b.as_para());
|
||||
assert!(first.is_some_and(|p| p.props.indent_left == Some(9.0) && list_kind(p).is_some()), "{first:?}");
|
||||
// An empty last paragraph without a section break goes.
|
||||
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
|
||||
update_generated(&mut s).unwrap();
|
||||
// (The field's paragraph shows nothing: there are no sources to list.)
|
||||
assert_eq!(texts(&s), ["", "Below"]);
|
||||
// Ending in its own paragraph: what follows comes after the list.
|
||||
let mut s = wrapped("Old entry. Then this\nBelow", 0, 0, "Old entry.".len());
|
||||
update_generated(&mut s).unwrap();
|
||||
let t = texts(&s);
|
||||
assert!(t.first().is_some_and(|t| !t.contains("Old")), "{t:?}");
|
||||
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
|
||||
assert_eq!(t.last().map(String::as_str), Some("Below"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! typing and deleting commands.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind, ControlLock, ControlRange, ListItem, MAX_CONTROL_TEXT, MAX_LIST_ITEMS};
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::props::{CharProps, Rgb, TextColor};
|
||||
@@ -172,7 +173,7 @@ fn placeholder_style_id(doc: &wordcraft_doc::Document) -> String {
|
||||
/// The placeholder style, added to the document when it hasn't one.
|
||||
fn ensure_placeholder_style(s: &mut Session) {
|
||||
if s.doc.styles.get(&placeholder_style_id(&s.doc)).is_none() {
|
||||
s.doc.styles.styles.push(Style {
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(Style {
|
||||
id: PLACEHOLDER_STYLE.into(),
|
||||
name: PLACEHOLDER_STYLE_NAME.into(),
|
||||
kind: StyleKind::Character,
|
||||
@@ -799,6 +800,8 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod editing_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::control::ControlKind;
|
||||
@@ -829,8 +832,8 @@ mod editing_tests {
|
||||
let typed_style = |run_props: Option<CharProps>| {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
|
||||
s.doc.styles.styles.push(style("Platzhaltertext", "Placeholder Text", grey));
|
||||
s.doc.styles.styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("Platzhaltertext", "Placeholder Text", grey));
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
|
||||
run(&mut s, "text.insert", json!({"text": "Name: "}));
|
||||
run(&mut s, "developer.plainText", json!({"tag": "name"}));
|
||||
let start = s.doc.control_at(&s.sel.focus).unwrap().start;
|
||||
@@ -852,7 +855,7 @@ mod editing_tests {
|
||||
// style goes all the same.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
|
||||
s.doc.styles.styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
|
||||
run(&mut s, "text.insert", json!({"text": "Nom : "}));
|
||||
run(&mut s, "developer.plainText", json!({"tag": "nom"}));
|
||||
let r = s.doc.control_at(&s.sel.focus).unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
@@ -95,8 +96,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn set_fonts(s: &mut Session, heading: &str, body: &str) {
|
||||
s.doc.settings.major_font = heading.into();
|
||||
s.doc.settings.minor_font = body.into();
|
||||
s.doc.styles.default_chr.font = Some(body.into());
|
||||
for st in &mut s.doc.styles.styles {
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.font = Some(body.into());
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
if let Some(f) = &st.chr.font
|
||||
&& (f == styles::HEADING_FONT || st.id.starts_with("Heading") || st.id == "Title")
|
||||
{
|
||||
@@ -108,7 +109,7 @@ fn set_fonts(s: &mut Session, heading: &str, body: &str) {
|
||||
}
|
||||
|
||||
fn set_accent(s: &mut Session, accent: Rgb) {
|
||||
for st in &mut s.doc.styles.styles {
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5')
|
||||
|| st.id == "IntenseQuote"
|
||||
|| st.id == "IntenseEmphasis"
|
||||
@@ -173,15 +174,16 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
// Overlay the set onto the document's sheet: every other style, the latent styles and the
|
||||
// document defaults stay as they are (no set defines its own defaults).
|
||||
let sheet = Arc::make_mut(&mut s.doc.styles);
|
||||
for mut st in fresh.styles {
|
||||
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
match sheet.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
Some(old) => {
|
||||
st.default = old.default;
|
||||
st.locked = old.locked;
|
||||
st.builtin = old.builtin;
|
||||
*old = st;
|
||||
}
|
||||
None => s.doc.styles.styles.push(st),
|
||||
None => sheet.styles.push(st),
|
||||
}
|
||||
}
|
||||
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
|
||||
@@ -200,9 +202,9 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"double" => (0.0, 8.0, 2.0),
|
||||
_ => (0.0, 8.0, 1.15),
|
||||
};
|
||||
s.doc.styles.default_para.space_before = Some(before);
|
||||
s.doc.styles.default_para.space_after = Some(after);
|
||||
s.doc.styles.default_para.line_spacing = Some(LineSpacing::Multiple(line));
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.space_before = Some(before);
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.space_after = Some(after);
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.line_spacing = Some(LineSpacing::Multiple(line));
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -227,7 +229,7 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
c.size = c.size.map(scale);
|
||||
c.size_cs = c.size_cs.map(scale);
|
||||
};
|
||||
let st = &mut s.doc.styles;
|
||||
let st = Arc::make_mut(&mut s.doc.styles);
|
||||
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
|
||||
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
|
||||
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
|
||||
|
||||
@@ -880,6 +880,7 @@ fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdErro
|
||||
};
|
||||
*kind = new_kind;
|
||||
*freeform = geometry;
|
||||
extra.odf_geometry = None;
|
||||
*fill = None;
|
||||
let [_, _, raw_w, raw_h] = found.bounds();
|
||||
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{Block, Document, StoryRef};
|
||||
|
||||
use super::{pos_json, sel_result};
|
||||
@@ -127,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
|
||||
.params(
|
||||
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
|
||||
r#"{"path": string, "pagesPerSheet"?: 1 | 2 | 4 | 6 | 9 | 16 | "booklet" | "bookletReverse", "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("file.print", "Print", "File", |s, _| {
|
||||
@@ -230,7 +231,7 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub(crate) fn normal_document(s: &Session) -> Document {
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -259,19 +260,19 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if bytes.is_empty() {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
|
||||
}
|
||||
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
|
||||
crate::io::open_path_in(std::path::Path::new(path), password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
|
||||
};
|
||||
// Plain text and Markdown carry no language: they are in the editing language.
|
||||
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
match s.used_because() {
|
||||
None => s.set_document(doc),
|
||||
@@ -345,7 +346,9 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
|
||||
};
|
||||
if !crate::io::can_encrypt(&path.to_string_lossy()) {
|
||||
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
|
||||
return Err(CmdError::Params(
|
||||
"only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password".into(),
|
||||
));
|
||||
}
|
||||
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
s.clear_recovery_before_password()?;
|
||||
@@ -380,7 +383,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
|
||||
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
|
||||
let own_file = s.path.as_deref() == Some(path.as_path());
|
||||
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
s.relayout();
|
||||
|
||||
@@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge;
|
||||
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
|
||||
use wordcraft_doc::section::SectionProps;
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, Revision, RevisionKind, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -212,6 +212,25 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
|
||||
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
|
||||
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
|
||||
/// another author's is superseded.
|
||||
pub(crate) fn record_para_props(s: &mut Session, story: StoryRef, path: &Path, before: &ParaProps) -> Result<(), crate::CmdError> {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(after) = s.doc.para(story, path).map(|p| p.props.clone()) else { return Ok(()) };
|
||||
let revisions = s.doc.revisions.clone();
|
||||
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &revisions, revisions: &mut s.doc.revisions, rid: None };
|
||||
if let Some(Replacement(want)) = r.change(before, &after, ParaProps::formatting, |p| &p.fmt_change) {
|
||||
let p = s.doc.para_mut(story, path)?;
|
||||
p.props.fmt_change = want;
|
||||
p.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted
|
||||
/// differently from `old` as tracked formatting changes by `author`.
|
||||
pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec<Revision>, author: &str, date: &str) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`; and
|
||||
//! the fonts the document's text asks for that aren't installed, with the fonts drawn instead
|
||||
//! (`font.missing`).
|
||||
//!
|
||||
//! An added font joins the process-wide font database on top of the bundled and system fonts
|
||||
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
|
||||
@@ -6,7 +8,9 @@
|
||||
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_fonts::{FontDb, UserFont};
|
||||
use wordcraft_layout::DocLayout;
|
||||
|
||||
use crate::cmd::insert::base64_decode;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
@@ -19,6 +23,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
|
||||
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
|
||||
CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(),
|
||||
CommandSpec::new("font.missing", "Missing Fonts", "Status Bar", missing)
|
||||
.params(r#"{} → {"missing": [{"font": string, "shownAs": [string] (the families drawn instead), "metricCompatible": bool (its substitute measures like the font)}]} — the text the current view shows"#)
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -117,6 +124,102 @@ fn remove(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// A font the document's text asks for that isn't installed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MissingFont {
|
||||
/// The name the document asks for.
|
||||
pub font: String,
|
||||
/// The families its text is drawn in instead: the substitute, and fallback faces for
|
||||
/// characters the substitute lacks.
|
||||
pub shown_as: Vec<String>,
|
||||
/// The font's substitute measures like it (Carlito for Calibri…): line breaks don't move.
|
||||
/// Fallback faces for characters the substitute lacks don't count: the font itself might
|
||||
/// lack them too (an emoji in Calibri text).
|
||||
pub metric_compatible: bool,
|
||||
}
|
||||
|
||||
/// The family text set in `font` is drawn in when `font` isn't installed (None when it is), as
|
||||
/// layout picks it for `doc`.
|
||||
pub fn shown_as(doc: &Document, font: &str) -> Option<String> {
|
||||
wordcraft_layout::para::substitute_face(doc, font).map(|f| f.get().family.clone())
|
||||
}
|
||||
|
||||
/// The family Chinese, Japanese and Korean text is drawn in when its East Asian font `font_ea`
|
||||
/// isn't installed (None when it is), the run's font being `font`, as layout picks it for `doc`.
|
||||
pub fn east_asian_shown_as(doc: &Document, font_ea: &str, font: &str) -> Option<String> {
|
||||
wordcraft_layout::para::east_asian_substitute_face(doc, font_ea, font).map(|f| f.get().family.clone())
|
||||
}
|
||||
|
||||
/// Whether text in `font` drawn in `substitute` is worth a warning: the substitute measures
|
||||
/// differently, so line breaks can move, and the font isn't a symbol font (drawn as its
|
||||
/// Unicode equivalents on purpose, which layout leaves out of [`missing_fonts`] too). Decides
|
||||
/// [`MissingFont::metric_compatible`], which the font box and the status bar both read.
|
||||
pub fn warns(font: &str, substitute: &str) -> bool {
|
||||
!wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::metric_compatible(font, substitute)
|
||||
}
|
||||
|
||||
/// The fonts `doc`'s laid-out text asks for that aren't installed, from what layout drew
|
||||
/// ([`DocLayout::substituted_fonts`]): only text the current view shows counts (no headers or
|
||||
/// footers in Web, Draft or Read view), in every story on its pages, list labels and field
|
||||
/// results included. Names differing only in case are one font.
|
||||
pub fn missing_fonts(doc: &Document, layout: &DocLayout) -> Vec<MissingFont> {
|
||||
// By key: in name order, case variants merged.
|
||||
let mut out: std::collections::BTreeMap<String, MissingFont> = std::collections::BTreeMap::new();
|
||||
for (recorded, face) in layout.substituted_fonts() {
|
||||
let family = face.get().family.clone();
|
||||
let font = shown_name(&recorded);
|
||||
let m = out.entry(font_key(font)).or_insert_with(|| MissingFont {
|
||||
font: font.to_string(),
|
||||
shown_as: Vec::new(),
|
||||
// Judged by the name layout looked up, spaces and all: "Arial " isn't Arial to it, so
|
||||
// its text is in a substitute even where Arial is installed.
|
||||
metric_compatible: shown_as(doc, &recorded).is_some_and(|s| !warns(font, &s)),
|
||||
});
|
||||
if !m.shown_as.contains(&family) {
|
||||
m.shown_as.push(family);
|
||||
}
|
||||
}
|
||||
out.into_values().collect()
|
||||
}
|
||||
|
||||
/// A font name as [`missing_fonts`] lists it: as layout records it (at most 256 characters),
|
||||
/// trimmed.
|
||||
fn shown_name(font: &str) -> &str {
|
||||
wordcraft_layout::para::reported_font_name(font).trim()
|
||||
}
|
||||
|
||||
/// The key a font name is listed under in [`missing_fonts`]: two names with the same key are
|
||||
/// one font. Look a document's font up in the list through this.
|
||||
pub fn font_key(font: &str) -> String {
|
||||
shown_name(font).to_lowercase()
|
||||
}
|
||||
|
||||
/// The status line's form of a font name: a hostile file's names are long.
|
||||
fn short_name(font: &str) -> String {
|
||||
match font.char_indices().nth(40) {
|
||||
Some((i, _)) => format!("{}…", font.get(..i).unwrap_or(font)),
|
||||
None => font.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let layout = s.layout();
|
||||
let found = missing_fonts(&s.doc, &layout);
|
||||
// From command search there's no dialog: the status line says what was found.
|
||||
// A hostile file can name thousands of fonts: name the first few.
|
||||
const NAMED: usize = 5;
|
||||
let changing: Vec<&str> = found.iter().filter(|f| !f.metric_compatible).map(|f| f.font.as_str()).collect();
|
||||
let named = changing.iter().take(NAMED).map(|f| short_name(f)).collect::<Vec<_>>().join(", ");
|
||||
s.status = match (found.is_empty(), changing.len()) {
|
||||
(true, _) => "Every font the document shows is installed".into(),
|
||||
(false, 0) => "Fonts that aren't installed are shown in fonts that measure the same: line breaks don't move".into(),
|
||||
(_, n) if n > NAMED => format!("Not installed: {named} and {} more", n - NAMED),
|
||||
_ => format!("Not installed: {named}"),
|
||||
};
|
||||
let fonts: Vec<Value> = found.iter().map(|f| json!({"font": f.font, "shownAs": f.shown_as, "metricCompatible": f.metric_compatible})).collect();
|
||||
Ok(json!({"missing": fonts}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -138,6 +138,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
apply(s, &|c| c.position = Some(x))
|
||||
})
|
||||
.params(r#"{"points": number}"#),
|
||||
// Font › Emphasis mark: a mark over (or under) each character.
|
||||
CommandSpec::new("format.emphasisMark", "Emphasis Mark", "Home › Font › Font", |s, v| {
|
||||
use wordcraft_doc::props::EmphasisMark;
|
||||
let name = p::str(v, "mark").unwrap_or("dot");
|
||||
let mark = EmphasisMark::ALL
|
||||
.into_iter()
|
||||
.find(|m| m.ooxml().eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown emphasis mark `{name}` (none, dot, comma, circle, underDot)")))?;
|
||||
apply(s, &|c| c.emphasis = Some(mark))
|
||||
})
|
||||
.params(r#"{"mark"?: "none" | "dot" | "comma" | "circle" | "underDot"}"#),
|
||||
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
|
||||
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
|
||||
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
|
||||
@@ -866,8 +877,14 @@ pub fn state(s: &Session) -> Value {
|
||||
let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a);
|
||||
let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
|
||||
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
|
||||
let font = same(&|r| json!(r.font));
|
||||
// The font's substitute when it isn't installed: the face its text is drawn in, apart from
|
||||
// characters the substitute lacks. `font.missing` lists every face the shown text is drawn
|
||||
// in, fallback faces too, and which fonts change the layout.
|
||||
let shown_as = font.as_str().and_then(|f| crate::cmd::font::shown_as(&s.doc, f));
|
||||
json!({
|
||||
"font": same(&|r| json!(r.font)),
|
||||
"font": font,
|
||||
"fontShownAs": shown_as,
|
||||
"fontEastAsia": same(&|r| json!(r.font_ea)),
|
||||
"size": same(&|r| json!(r.size)),
|
||||
"bold": all.iter().all(|r| r.bold),
|
||||
@@ -882,6 +899,7 @@ pub fn state(s: &Session) -> Value {
|
||||
"caps": all.iter().all(|r| r.caps),
|
||||
"hidden": all.iter().all(|r| r.hidden),
|
||||
"spacing": same(&|r| json!(r.spacing)),
|
||||
"emphasisMark": same(&|r| json!(r.emphasis.ooxml())),
|
||||
// Kerning for fonts: the smallest kerned size, points; 0 = off.
|
||||
"kern": same(&|r| json!(r.kern)),
|
||||
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
|
||||
|
||||
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
|
||||
|
||||
use super::references::{FieldCode, leader};
|
||||
use super::references::{FieldCode, field_arg, leader};
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
@@ -44,11 +44,12 @@ fn xe_instr(s: &Session, v: &Value, main: &str) -> Result<String, CmdError> {
|
||||
let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") };
|
||||
if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) {
|
||||
instr.push_str(&format!(" \\t \"{see}\""));
|
||||
} else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) {
|
||||
} else if let Some(bm) = p::str(v, "bookmark").map(str::trim).filter(|b| !b.is_empty()) {
|
||||
if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) {
|
||||
return Err(CmdError::Params(format!("no bookmark `{bm}`")));
|
||||
}
|
||||
instr.push_str(&format!(" \\r {}", bm.trim()));
|
||||
// A name read from a file may hold a space, a quote or a backslash.
|
||||
instr.push_str(&format!(" \\r {}", field_arg(bm)));
|
||||
}
|
||||
if p::bool(v, "bold").unwrap_or(false) {
|
||||
instr.push_str(" \\b");
|
||||
@@ -393,6 +394,36 @@ mod tests {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect()
|
||||
}
|
||||
|
||||
/// #3669: the bookmark is looked up trimmed, and a name with a space or a quote (one a file
|
||||
/// can hold) is quoted in the XE field, so it reads back whole.
|
||||
#[test]
|
||||
fn mark_entry_trims_and_quotes_its_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro text.\nMore text."));
|
||||
s.run("select.text", &json!({"text": "Intro"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Intro"})).unwrap();
|
||||
let odd = "My \"Mark\" \\b";
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: odd.into() }, &CharProps::default()).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Alpha", "bookmark": " Intro "})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Beta", "bookmark": odd})).unwrap();
|
||||
let mut instrs: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } if instr.starts_with("XE") => Some(instr.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
instrs.sort();
|
||||
assert_eq!(instrs, vec![r#"XE "Alpha" \r Intro"#, r#"XE "Beta" \r "My \"Mark\" \\b""#]);
|
||||
let beta = FieldCode::parse(&instrs[1]);
|
||||
assert_eq!((beta.arg('r'), beta.has('b')), (Some(odd), false));
|
||||
}
|
||||
|
||||
/// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and
|
||||
/// the INDEX field's switches change the index: run-in subentries, letter headings, a tab
|
||||
/// before right-aligned page numbers, and two columns in a continuous section.
|
||||
|
||||
@@ -83,6 +83,28 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars.get(i).copied().unwrap_or(' ');
|
||||
// Text in single quotes is shown as it is; two quotes in a row are one, inside quoted
|
||||
// text or out of it.
|
||||
if c == '\'' && chars.get(i + 1) == Some(&'\'') {
|
||||
out.push('\'');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if c == '\'' {
|
||||
i += 1;
|
||||
while let Some(&q) = chars.get(i) {
|
||||
i += 1;
|
||||
if q != '\'' {
|
||||
out.push(q);
|
||||
} else if chars.get(i) == Some(&'\'') {
|
||||
out.push('\'');
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// `am/pm` (any case) is one token: AM or PM.
|
||||
if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::<String>().eq_ignore_ascii_case("am/pm")) {
|
||||
out.push_str(if hh < 12 { "AM" } else { "PM" });
|
||||
|
||||
@@ -137,7 +137,8 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
|
||||
let (mut w, mut h) = (natural_w, natural_h);
|
||||
if let Some(want) = width {
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
// At most 22 inches, as for icons: a huge width made the height of a tall picture infinite.
|
||||
let k = want.clamp(4.0, 1584.0) / w.max(1.0);
|
||||
w *= k;
|
||||
h *= k;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
use super::*;
|
||||
|
||||
/// #3619: a huge finite width can't make a tall picture's height infinite, which a `.json` save
|
||||
/// writes as `null` and can't read back.
|
||||
#[test]
|
||||
fn a_huge_picture_width_keeps_the_size_finite() {
|
||||
let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap();
|
||||
let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap());
|
||||
assert!(w.is_finite() && h.is_finite() && (h / w - 200.0).abs() < 0.01, "{w} x {h}");
|
||||
let saved = crate::io::save_bytes("a.json", &s.doc).unwrap();
|
||||
assert!(crate::io::open_bytes("a.json", &saved).is_ok());
|
||||
}
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
@@ -334,6 +349,18 @@ fn symbol_takes_its_own_font() {
|
||||
assert!(s.run("insert.symbol", &json!({"char": ""})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_pictures_keep_quoted_text() {
|
||||
// Text in single quotes is shown as it is (Word's date-time picture); two quotes are one.
|
||||
assert_eq!(format_date_parts("H' o''clock'", 2026, 10, 21, (8, 0, 0), 0), "8 o'clock");
|
||||
assert_eq!(format_date_parts("'Day' d 'at' h", 2026, 10, 21, (17, 0, 0), 0), "Day 21 at 5");
|
||||
// An unclosed quote runs to the end.
|
||||
assert_eq!(format_date_parts("yyyy 'and so", 2026, 10, 21, (0, 0, 0), 0), "2026 and so");
|
||||
// Two quotes outside quoted text are one too (an apostrophe), not an empty quoted text.
|
||||
assert_eq!(format_date_parts("yyyy '' MM", 2026, 10, 21, (0, 0, 0), 0), "2026 ' 10");
|
||||
assert_eq!(format_date_parts("d''MM", 2026, 10, 21, (0, 0, 0), 0), "21'10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_pictures() {
|
||||
// Word accepts either case for the day and year instructions.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! the number and the number's font.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for};
|
||||
use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor};
|
||||
@@ -183,26 +184,26 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(n) => {
|
||||
// Change the list in place: every list sharing its definition follows.
|
||||
let aid = s.doc.numbering.num(n).map(|x| x.abstract_id).ok_or_else(|| CmdError::Params("no such list".into()))?;
|
||||
if let Some(a) = s.doc.numbering.abstracts.iter_mut().find(|a| a.id == aid) {
|
||||
if let Some(a) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == aid) {
|
||||
a.levels = levels.clone();
|
||||
if name.is_some() {
|
||||
a.name = name;
|
||||
}
|
||||
}
|
||||
if let Some(x) = s.doc.numbering.nums.iter_mut().find(|x| x.id == n) {
|
||||
if let Some(x) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == n) {
|
||||
x.level_overrides.clear();
|
||||
}
|
||||
let sharing: Vec<u32> = s.doc.numbering.nums.iter().filter(|x| x.abstract_id == aid).map(|x| x.id).collect();
|
||||
touch_lists(s, &sharing)?;
|
||||
n
|
||||
}
|
||||
None => s.doc.numbering.try_add_levels(name, levels.clone())?,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_levels(name, levels.clone())?,
|
||||
};
|
||||
// Levels linked to a paragraph style number every paragraph in that style.
|
||||
let mut linked = Vec::new();
|
||||
for (i, l) in levels.iter().enumerate() {
|
||||
let Some(id) = l.style.as_deref().map(|st| s.doc.styles.find(st).map(|x| x.id.clone()).unwrap_or_else(|| st.to_string())) else { continue };
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.para.numbering = Some(NumRef { num, level: i as u8 });
|
||||
linked.push(id);
|
||||
}
|
||||
@@ -292,11 +293,11 @@ fn preset_levels(preset: &str) -> Option<Vec<Level>> {
|
||||
fn preset(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = p::req_str(v, "preset")?;
|
||||
let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?;
|
||||
let num = s.doc.numbering.try_add_levels(None, levels)?;
|
||||
let num = Arc::make_mut(&mut s.doc.numbering).try_add_levels(None, levels)?;
|
||||
let mut linked = Vec::new();
|
||||
for n in 1..=LEVELS as u8 {
|
||||
let id = super::para::ensure_heading_style(s, n);
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.para.numbering = Some(NumRef { num, level: n - 1 });
|
||||
linked.push(id);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use wordcraft_doc::props::{Align, CharProps};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Part, PartKind, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::references::{field_arg, field_words, quoted};
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
@@ -122,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| {
|
||||
let f = p::req_str(v, "field")?.to_string();
|
||||
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
|
||||
insert_field(s, &format!("MERGEFIELD {}", field_arg(&f)), &format!("«{f}»"))
|
||||
})
|
||||
.params(r#"{"field": string}"#)
|
||||
.key(super::non_mac("Alt+Shift+F")),
|
||||
@@ -136,14 +137,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase();
|
||||
let instr = match rule.as_str() {
|
||||
"IF" => format!(
|
||||
"IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"",
|
||||
quote(p::str(v, "field").unwrap_or("Field")),
|
||||
p::str(v, "value").unwrap_or(""),
|
||||
p::str(v, "then").unwrap_or(""),
|
||||
p::str(v, "else").unwrap_or("")
|
||||
"IF {{ MERGEFIELD {} }} = {} {} {}",
|
||||
field_arg(p::str(v, "field").unwrap_or("Field")),
|
||||
quoted(p::str(v, "value").unwrap_or("")),
|
||||
quoted(p::str(v, "then").unwrap_or("")),
|
||||
quoted(p::str(v, "else").unwrap_or(""))
|
||||
),
|
||||
"SKIPIF" | "NEXTIF" => {
|
||||
format!("{rule} {{ MERGEFIELD {} }} = \"{}\"", quote(p::str(v, "field").unwrap_or("Field")), p::str(v, "value").unwrap_or(""))
|
||||
format!("{rule} {{ MERGEFIELD {} }} = {}", field_arg(p::str(v, "field").unwrap_or("Field")), quoted(p::str(v, "value").unwrap_or("")))
|
||||
}
|
||||
"MERGEREC" => "MERGEREC".into(),
|
||||
_ => "NEXT".into(),
|
||||
@@ -227,10 +228,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn quote(f: &str) -> String {
|
||||
if f.contains(' ') { format!("\"{f}\"") } else { f.to_string() }
|
||||
}
|
||||
|
||||
fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value,
|
||||
json, p, para_block,
|
||||
field_words, json, p, para_block,
|
||||
};
|
||||
|
||||
pub(super) fn step(s: &mut Session, d: i64) -> CmdResult {
|
||||
@@ -41,9 +41,7 @@ pub(super) fn mergefield_name(instr: &str) -> Option<String> {
|
||||
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.trim();
|
||||
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
|
||||
Some(name.to_string())
|
||||
Some(field_words(rest).into_iter().next().map(|(name, _)| name).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Which columns hold address parts.
|
||||
@@ -103,17 +101,17 @@ fn rule_field(t: &str) -> Option<String> {
|
||||
|
||||
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
|
||||
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
|
||||
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
|
||||
let field = rule_field(t).unwrap_or_default();
|
||||
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
|
||||
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
|
||||
let mut quoted = rest.split('"').skip(1).step_by(2);
|
||||
let cmp = quoted.next().unwrap_or("");
|
||||
let mut texts = field_words(rest).into_iter().filter(|(_, q)| *q).map(|(w, _)| w);
|
||||
let cmp = texts.next().unwrap_or_default();
|
||||
let v = value(s, row, &field);
|
||||
// Numbers compare as numbers (Word: "7" = "7.0"), everything else as text.
|
||||
let ord = match (v.trim().parse::<f64>(), cmp.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => a.partial_cmp(&b),
|
||||
_ => Some(v.as_str().cmp(cmp)),
|
||||
_ => Some(v.as_str().cmp(cmp.as_str())),
|
||||
};
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
let holds = match op.as_str() {
|
||||
@@ -124,7 +122,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
">=" => matches!(ord, Some(Greater | Equal)),
|
||||
_ => ord == Some(Equal),
|
||||
};
|
||||
(holds, quoted.collect())
|
||||
(holds, texts.collect())
|
||||
}
|
||||
|
||||
/// The first word of a field instruction, upper-cased.
|
||||
@@ -149,7 +147,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
|
||||
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
|
||||
"IF" if rule_field(t).is_some() => {
|
||||
let (holds, texts) = condition(s, t, row);
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).cloned().unwrap_or_default())
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -307,6 +307,29 @@ fn if_rule_previews_its_result() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Selected\nOther");
|
||||
}
|
||||
|
||||
/// #3681: quotes and backslashes in a rule's texts and field name stay in them.
|
||||
#[test]
|
||||
fn rule_texts_keep_their_quotes() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let rows = json!([{"Say \"hi\"": "a\\b", "Name": "O\"Neil"}, {"Say \"hi\"": "no", "Name": "Plain"}]);
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
let rule = json!({"rule": "IF", "field": "Say \"hi\"", "value": "a\\b", "then": "Dear \"friend\"", "else": "C:\\dir\\"});
|
||||
s.run("mailings.rules", &rule).unwrap();
|
||||
s.run("mailings.preview", &json!({"value": true})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Dear \"friend\"");
|
||||
s.run("mailings.next", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "C:\\dir\\");
|
||||
// SKIPIF compares with the value as typed.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
s.run("mailings.rules", &json!({"rule": "SKIPIF", "field": "Name", "value": "O\"Neil"})).unwrap();
|
||||
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Plain");
|
||||
}
|
||||
|
||||
/// #430: merge fields in headers and footers are previewed and merged; each letter gets its
|
||||
/// own header.
|
||||
#[test]
|
||||
|
||||
@@ -309,8 +309,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Shape { kind: k, .. } = o {
|
||||
if let InlineObject::Shape { kind: k, extra, .. } = o {
|
||||
*k = kind;
|
||||
// A geometry kept from a file is no longer this shape's.
|
||||
extra.odf_geometry = None;
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Prefs, Session, p};
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
|
||||
/// Options panes, in the order the page lists them (the `pane` param of `file.options`).
|
||||
pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents"];
|
||||
@@ -22,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("file.options", "Options", "File", options)
|
||||
.params(
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: bool, "widowControl"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
|
||||
@@ -145,6 +146,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(_) => return Err(CmdError::Params("`marks` must be an object of true/false".into())),
|
||||
}
|
||||
let check_spelling = bool_param(v, "checkSpelling")?;
|
||||
let odf = odf_param(v, &s.prefs.odf)?;
|
||||
let add = words_param(v, "addWords")?;
|
||||
let remove = words_param(v, "removeWords")?;
|
||||
|
||||
@@ -157,6 +159,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|| !flag_values.is_empty()
|
||||
|| !mark_values.is_empty()
|
||||
|| check_spelling.is_some()
|
||||
|| odf.is_some()
|
||||
|| !add.is_empty()
|
||||
|| !remove.is_empty();
|
||||
if let Some(name) = author {
|
||||
@@ -188,6 +191,10 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.view.proofing = b;
|
||||
s.prefs.check_spelling = b;
|
||||
}
|
||||
// ODF files opened from now on fill in what their standard leaves open this way.
|
||||
if let Some(o) = odf {
|
||||
s.prefs.odf = o;
|
||||
}
|
||||
for w in &add {
|
||||
wordcraft_proof::add_word(w);
|
||||
}
|
||||
@@ -233,6 +240,9 @@ pub fn current(s: &Session) -> Value {
|
||||
"dictionary": wordcraft_proof::user_dictionary(),
|
||||
"dictionariesFolder": pr.dictionaries_folder,
|
||||
"userTemplatesFolder": pr.user_templates_folder,
|
||||
// How ODF files fill in what the standard leaves open: the chosen preset, the values
|
||||
// that apply, and the custom values kept for when the preset is custom.
|
||||
"odf": {"preset": pr.odf.preset, "effective": pr.odf.effective(), "custom": pr.odf},
|
||||
"dictionaries": wordcraft_proof::installed_dictionaries()
|
||||
.into_iter()
|
||||
.map(|(lang, dic)| json!({"language": lang.tag(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
|
||||
@@ -240,6 +250,45 @@ pub fn current(s: &Session) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `odf` setting: `{"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), …case values}` over `current`.
|
||||
/// A case value given without a preset makes the profile custom; the values are kept whatever
|
||||
/// the preset, so choosing custom again brings them back. Lengths are points, at most 22 inches;
|
||||
/// colours `RRGGBB`.
|
||||
fn odf_param(v: &Value, current: &OdfProfile) -> Result<Option<OdfProfile>, CmdError> {
|
||||
let Some(o) = v.get("odf").filter(|o| !o.is_null()) else { return Ok(None) };
|
||||
let obj = o.as_object().ok_or_else(|| CmdError::Params("`odf` must be an object".into()))?;
|
||||
let mut merged = serde_json::to_value(current).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let known: Vec<String> = merged.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default();
|
||||
let mut values = false;
|
||||
for (k, x) in obj {
|
||||
if !known.contains(k) {
|
||||
return Err(CmdError::Params(format!("`odf.{k}`: not a setting; one of {}", known.join(", "))));
|
||||
}
|
||||
if let Some(n) = x.as_f64().filter(|_| k != "preset")
|
||||
&& !(n.is_finite() && (0.0..=f64::from(wordcraft_doc::odf_profile::MAX_LENGTH)).contains(&n))
|
||||
{
|
||||
return Err(CmdError::Params(format!("`odf.{k}`: a length from 0 to {} points", wordcraft_doc::odf_profile::MAX_LENGTH)));
|
||||
}
|
||||
let x = if k == "preset" {
|
||||
let p = x.as_str().and_then(OdfPreset::from_id).ok_or_else(|| CmdError::Params("`odf.preset`: word, libreoffice or custom".into()))?;
|
||||
json!(p)
|
||||
} else {
|
||||
values = true;
|
||||
x.clone()
|
||||
};
|
||||
if let Some(m) = merged.as_object_mut() {
|
||||
m.insert(k.clone(), x);
|
||||
}
|
||||
}
|
||||
if values
|
||||
&& !obj.contains_key("preset")
|
||||
&& let Some(m) = merged.as_object_mut()
|
||||
{
|
||||
m.insert("preset".into(), json!(OdfPreset::Custom));
|
||||
}
|
||||
serde_json::from_value(merged).map(Some).map_err(|e| CmdError::Params(format!("`odf`: {e}")))
|
||||
}
|
||||
|
||||
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
|
||||
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
|
||||
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
|
||||
|
||||
@@ -286,21 +286,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_only_sets_the_pref_and_nothing_else() {
|
||||
fn set_as_default_sets_the_pref_and_nothing_else() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let (doc, sel) = (s.doc.styles.default_chr.lang.clone(), s.sel.clone());
|
||||
let normal = s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone());
|
||||
// A redo step exists: the setting is no edit, so it neither adds an undo step nor clears it.
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let rev = s.rev();
|
||||
s.run("review.language", &json!({"lang": "nl-NL", "preferredOnly": true})).unwrap();
|
||||
let (depth, redo, dirty) = (s.undo_depth(), s.can_redo(), s.dirty);
|
||||
assert!(redo);
|
||||
s.run("review.language.preferred", &json!({"lang": "nl-NL"})).unwrap();
|
||||
assert_eq!((s.undo_depth(), s.can_redo(), s.dirty), (depth, redo, dirty), "history, redo and modified flag are kept");
|
||||
assert_eq!(s.prefs.default_language.as_deref(), Some("nl-NL"));
|
||||
assert_eq!(s.doc.styles.default_chr.lang, doc);
|
||||
assert_eq!(s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone()), normal);
|
||||
assert_eq!(s.sel, sel);
|
||||
assert_eq!(packs::document_languages(s), ["en-US"], "the text keeps its language");
|
||||
assert_eq!(s.rev(), rev);
|
||||
assert!(s.run("review.language", &json!({"preferredOnly": true})).is_err());
|
||||
assert!(s.run("review.language.preferred", &json!({})).is_err());
|
||||
assert!(s.run("review.language.preferred", &json!({"lang": "not a tag!!"})).is_err());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -655,6 +662,34 @@ mod tests {
|
||||
assert_eq!(messages(pt_pt), messages(ProofLang::Pt), "Portugal text gets the Portuguese grammar checks");
|
||||
}
|
||||
|
||||
/// The one region test and region-dropping step the engine and the UI share: two letters or
|
||||
/// three digits after the language; hostile text never panics.
|
||||
#[test]
|
||||
fn a_region_is_two_letters_or_three_digits() {
|
||||
for r in ["NL", "nl", "419", "ZZ"] {
|
||||
assert!(packs::is_region(r), "{r}");
|
||||
}
|
||||
for r in ["", "n", "nld", "4190", "41", "Latn", "valencia", "é1", "\u{202e}\u{202e}"] {
|
||||
assert!(!packs::is_region(r), "{r:?}");
|
||||
}
|
||||
for (tag, bare) in [
|
||||
("nl-NL", "nl"),
|
||||
("sr-Latn-RS", "sr-Latn"),
|
||||
("ca-ES-valencia", "ca-valencia"),
|
||||
("es_419", "es"),
|
||||
("pt", "pt"),
|
||||
("en-US-x-test", "en-x-test"),
|
||||
("", ""),
|
||||
("-", "-"),
|
||||
] {
|
||||
assert_eq!(packs::without_region(tag), bare, "{tag}");
|
||||
}
|
||||
// The language itself is never a region, even when it looks like one.
|
||||
assert_eq!(packs::without_region("de"), "de");
|
||||
let _ = packs::without_region(&"-".repeat(10_000));
|
||||
let _ = packs::without_region("\u{0}-\u{202e}");
|
||||
}
|
||||
|
||||
/// A font store that has never fetched the list (a fresh install), or whose cached list is
|
||||
/// for an older pin: `index` has nothing until a fetch.
|
||||
struct Uncached(FixedBackend);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||||
@@ -256,7 +257,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let on = p::bool(v, "value").unwrap_or(true);
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.quick = on;
|
||||
}
|
||||
sel_result(s)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::format::fmt;
|
||||
use super::{CmdError, CmdResult, LineSpacing, ListKind, NumRef, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
|
||||
pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
@@ -45,9 +46,9 @@ pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
None => match kind {
|
||||
ListKind::Bullet | ListKind::BulletChar(_) => match s.doc.numbering.find_kind(kind) {
|
||||
Some(n) => n,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
},
|
||||
_ => s.doc.numbering.try_add_list(kind)?,
|
||||
_ => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
},
|
||||
};
|
||||
fmt(s, &|p| {
|
||||
@@ -68,7 +69,7 @@ pub(super) fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else {
|
||||
return Err(CmdError::Failed("not in a numbered list".into()));
|
||||
};
|
||||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
// This paragraph and the following ones of the same list use the new instance.
|
||||
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect();
|
||||
for path in paths {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::lists::touch_all;
|
||||
use super::{Block, CmdError, CmdResult, NumRef, ParaProps, Pos, Session, SortKey, Value, p, sel_result, sort_key};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Move Paragraph Up / Down (Alt+Shift+Up/Down, ⌃⇧↑/↓ on macOS): the paragraphs the selection
|
||||
/// touches swap places with the paragraph above or below, in their own container (the story or
|
||||
@@ -96,7 +97,7 @@ pub(crate) fn ensure_heading_style(s: &mut Session, n: u8) -> String {
|
||||
if s.doc.styles.get(&sid).is_none()
|
||||
&& let Some(st) = builtin.get(&sid).cloned()
|
||||
{
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
}
|
||||
}
|
||||
touch_all(s);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::format::fmt;
|
||||
use super::lists::touch_all;
|
||||
use super::{CharProps, CmdError, CmdResult, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn style_id(s: &Session, name: &str) -> Result<String, CmdError> {
|
||||
s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`")))
|
||||
@@ -101,7 +102,7 @@ pub(super) fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
st.chr.fmt_change = None;
|
||||
st.para = st.para.formatting();
|
||||
}
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
apply_style(s, &json!({"style": id}))?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -113,7 +114,7 @@ pub(super) fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let next = p::str(v, "next").map(str::to_string);
|
||||
let name = p::str(v, "name").map(str::to_string);
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if let Some(c) = chr {
|
||||
st.chr.overlay(&c);
|
||||
}
|
||||
@@ -175,7 +176,7 @@ pub(super) fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let chr = retarget(&target.chr, &have_c, &want_c, CHAR_KEEP)?;
|
||||
let pp = if target.kind == StyleKind::Paragraph { retarget(&target.para, &have_p, &want_p, PARA_KEEP)? } else { target.para.clone() };
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.chr = chr;
|
||||
st.para = pp;
|
||||
}
|
||||
@@ -263,7 +264,7 @@ pub(super) fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let mut changed = 0;
|
||||
for g in &gone {
|
||||
s.doc.styles.remove(&g.id);
|
||||
Arc::make_mut(&mut s.doc.styles).remove(&g.id);
|
||||
// Fall back to the base only while it is a style of the same kind that still exists.
|
||||
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
|
||||
changed += s.doc.restyle(&g.id, base);
|
||||
@@ -279,7 +280,7 @@ pub(super) fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if gallery.is_none() && hidden.is_none() {
|
||||
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
|
||||
}
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
if let Some(h) = hidden {
|
||||
st.hidden = h;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::{InlineObject, OBJ};
|
||||
use wordcraft_doc::props::{CellProps, CharProps, RowProps, TableProps};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Path, PermRange, Pos, StoryRef};
|
||||
@@ -78,7 +79,7 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Disabled("stop protection before changing the formatting restrictions".into()));
|
||||
}
|
||||
let names: Vec<&str> = list.iter().filter_map(Value::as_str).take(10_000).collect();
|
||||
for st in &mut s.doc.styles.styles {
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
st.locked = names.iter().any(|n| *n == st.id || n.eq_ignore_ascii_case(&st.name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ fn read_only_exception_around_a_table_lets_it_be_formatted() {
|
||||
#[test]
|
||||
fn formatting_restriction_refuses_attach_template_update() {
|
||||
let mut t = Document::from_text("Template.");
|
||||
if let Some(st) = t.styles.styles.iter_mut().find(|st| st.id == "Heading1") {
|
||||
if let Some(st) = Arc::make_mut(&mut t.styles).styles.iter_mut().find(|st| st.id == "Heading1") {
|
||||
st.chr.size = Some(31.0);
|
||||
}
|
||||
let data = crate::cmd::insert::base64_encode(&crate::io::save_bytes("t.docx", &t).unwrap());
|
||||
@@ -147,7 +147,7 @@ fn formatting_restriction_refuses_attach_template_update() {
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
// Copying styles in replaces their definitions, locked flag included (#3692).
|
||||
s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string();
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
|
||||
@@ -16,7 +16,9 @@ mod properties;
|
||||
mod toc;
|
||||
|
||||
use fields::caption;
|
||||
pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq};
|
||||
pub use fields::{
|
||||
CHAPTER_SEPARATORS, REFERENCE_NOT_FOUND, output_fields, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq,
|
||||
};
|
||||
pub(super) use notes::note_options;
|
||||
use notes::{note, note_refs};
|
||||
pub use properties::{bookmark_text, date_picture};
|
||||
@@ -110,37 +112,61 @@ pub struct FieldCode {
|
||||
pub switches: Vec<(char, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A field instruction's words, each with whether it was quoted (see [`FieldCode`]).
|
||||
pub fn field_words(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// `s` as quoted field-code text, which [`field_words`] reads back as it was.
|
||||
pub fn quoted(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 2);
|
||||
o.push('"');
|
||||
for c in s.chars() {
|
||||
if matches!(c, '"' | '\\') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o.push('"');
|
||||
o
|
||||
}
|
||||
|
||||
/// `s` as one field-code argument: as it is when it's a single plain word, else [`quoted`].
|
||||
pub fn field_arg(s: &str) -> String {
|
||||
if s.is_empty() || s.contains(|c: char| c.is_whitespace() || matches!(c, '"' | '\\')) { quoted(s) } else { s.to_string() }
|
||||
}
|
||||
|
||||
impl FieldCode {
|
||||
pub fn parse(instr: &str) -> FieldCode {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
let mut words = words.into_iter().peekable();
|
||||
let mut words = field_words(instr).into_iter().peekable();
|
||||
let mut f =
|
||||
FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() };
|
||||
while let Some((w, quoted)) = words.next() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::properties::{
|
||||
bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result,
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
};
|
||||
use super::toc::{heading_style_level, update_toc};
|
||||
use super::{
|
||||
@@ -76,66 +77,219 @@ pub(super) fn caption(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// the last level-n heading's number, or its count when the heading isn't numbered) and the
|
||||
/// text or number of the nearest paragraph of a style.
|
||||
pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
let mut headings = [0u32; 9];
|
||||
let mut seq: std::collections::HashMap<String, (u32, u32)> = Default::default();
|
||||
// Built on the first STYLEREF (field results don't change what it finds).
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
// Numbers first: a STYLEREF to a heading that holds a SEQ field shows its new number.
|
||||
let numbers = seq_results(&s.doc);
|
||||
apply_results(&mut s.doc, numbers)?;
|
||||
let refs = styleref_results(&s.doc);
|
||||
apply_results(&mut s.doc, refs)
|
||||
}
|
||||
|
||||
/// A field's fresh result: its story and paragraph, its object index there and the text.
|
||||
type FieldResult = (StoryRef, Path, usize, String);
|
||||
|
||||
/// The SEQ numbering so far while the document is walked in order.
|
||||
#[derive(Default)]
|
||||
struct SeqCount {
|
||||
/// How many headings of each level there have been (for `\s`).
|
||||
headings: [u32; 9],
|
||||
/// Per sequence: its number, and the heading count it was last restarted at.
|
||||
seq: std::collections::HashMap<String, (u32, u32)>,
|
||||
/// Text boxes already counted: a story shown twice, or inside itself, counts once.
|
||||
boxes: std::collections::HashSet<u32>,
|
||||
out: Vec<FieldResult>,
|
||||
}
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(l) = heading_style_level(p)
|
||||
&& let Some(k) = (l as usize).checked_sub(1)
|
||||
&& let Some(count) = headings.get_mut(k)
|
||||
&& let Some(n) = count.headings.get_mut(k)
|
||||
{
|
||||
*count = count.saturating_add(1);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
let mut updates: Vec<(usize, String)> = Vec::new();
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
match f.name.as_str() {
|
||||
"SEQ" => {
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart =
|
||||
f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied();
|
||||
let n = seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
|
||||
}
|
||||
"STYLEREF" => {
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
count_seq(doc, StoryRef::Body, &path, p, &mut count);
|
||||
}
|
||||
count.out
|
||||
}
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
for bp in doc.para_paths(part) {
|
||||
if let Some(boxed) = doc.para(part, &bp) {
|
||||
count_seq(doc, part, &bp, boxed, count);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart = f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| count.headings.get(l)).copied();
|
||||
let n = count.seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
}
|
||||
}
|
||||
|
||||
/// The fresh results of the body's unlocked STYLEREF fields ([`update_seq`]), from the text
|
||||
/// `doc` holds: the SEQ fields in the headings they find should already show their numbers.
|
||||
fn styleref_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
// Built on the first STYLEREF.
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
if FieldCode::parse(instr).name != "STYLEREF" {
|
||||
continue;
|
||||
}
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What Word prints for a `REF` to a bookmark the document doesn't have.
|
||||
pub const REFERENCE_NOT_FOUND: &str = "Error! Reference source not found.";
|
||||
|
||||
/// The fresh results of the body's unlocked REF fields as output shows them: the bookmark's
|
||||
/// text, or Word's error when the document lacks the bookmark (measured in Word for Windows);
|
||||
/// `\p` ("above" or "below") and the paragraph number switches `\r`, `\n` and `\w` keep the
|
||||
/// stored result, and so does a REF to a bookmark that ends in a later paragraph: Word shows such
|
||||
/// a bookmark's paragraphs as paragraphs of the result, which a field's one-paragraph result
|
||||
/// can't hold (measured in Word for Windows). A bookmark in a header, footer, footnote, endnote,
|
||||
/// comment or text box is found too, and a REF to it shows that text as `stored` holds it, its
|
||||
/// own fields not yet refreshed, as Word's output does (measured in Word for Windows). The
|
||||
/// document's bookmarks are looked up once, and each bookmark's text read once, however many
|
||||
/// fields refer to it.
|
||||
fn ref_results(doc: &wordcraft_doc::Document, stored: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
let mut starts: std::collections::HashMap<String, Pos> = std::collections::HashMap::new();
|
||||
for (name, start) in doc.bookmarks().into_iter().chain(part_bookmarks(doc)) {
|
||||
// The first of two bookmarks with one name, as `bookmark_text` takes.
|
||||
starts.entry(name).or_insert(start);
|
||||
}
|
||||
// A bookmark's text, `None` when it ends in a later paragraph.
|
||||
let mut texts: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
// `\r`, `\n` and `\w` show the bookmarked paragraph's number, which isn't computed
|
||||
// here: those keep their stored result rather than print the paragraph's text.
|
||||
if wordcraft_layout::fields::field_name(instr) != "REF" || ['p', 'r', 'n', 'w'].iter().any(|&sw| has_switch(instr, sw)) {
|
||||
continue;
|
||||
}
|
||||
let f = FieldCode::parse(instr);
|
||||
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
|
||||
let Some(start) = starts.get(target) else {
|
||||
out.push((StoryRef::Body, path.clone(), k, REFERENCE_NOT_FOUND.to_string()));
|
||||
continue;
|
||||
};
|
||||
let text = texts.entry(target.to_string()).or_insert_with(|| {
|
||||
let source = if start.story == StoryRef::Body { doc } else { stored };
|
||||
bookmark_ends_in_its_paragraph(source, target, start).then(|| bookmark_text_at(source, target, start)).flatten()
|
||||
});
|
||||
if let Some(r) = text.clone() {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, r)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bookmarks outside the body (headers, footers, notes, comments and text boxes), in story
|
||||
/// order.
|
||||
fn part_bookmarks(doc: &wordcraft_doc::Document) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for id in doc.parts.keys() {
|
||||
let story = StoryRef::Part(*id);
|
||||
for path in doc.para_paths(story) {
|
||||
let Some(p) = doc.para(story, &path) else { continue };
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if let InlineObject::BookmarkStart { name } = o {
|
||||
out.push((name.clone(), Pos { story, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Put `results` into their fields, clearing `w:dirty` (a computed result is no longer stale:
|
||||
/// Word's F9 clears it too).
|
||||
fn apply_results(doc: &mut wordcraft_doc::Document, results: Vec<FieldResult>) -> Result<(), CmdError> {
|
||||
for (story, path, k, r) in results {
|
||||
let para = doc.para_mut(story, &path)?;
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `results` less those its fields already show.
|
||||
fn changed(doc: &wordcraft_doc::Document, mut results: Vec<FieldResult>) -> Vec<FieldResult> {
|
||||
results.retain(|(story, path, k, r)| {
|
||||
!matches!(doc.para(*story, path).and_then(|p| p.objects.get(*k)), Some(InlineObject::Field { result, .. }) if result == r)
|
||||
});
|
||||
results
|
||||
}
|
||||
|
||||
/// The document as Word prints and exports it (PDF, print jobs, page images). Laying a document
|
||||
/// out for output, Word fills in the body's unlocked SEQ, STYLEREF and REF fields, even with
|
||||
/// "Update fields before printing" off, while NUMWORDS, FILENAME, the summary properties and
|
||||
/// formulas keep their stored results (measured with Word for Windows, printing and exporting
|
||||
/// fields whose stored results were wrong). The document itself keeps its stored results.
|
||||
/// `None` when every such field already shows its fresh result, so nothing is copied then.
|
||||
pub fn output_fields(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::Document> {
|
||||
// Numbers first, then the fields that read them: a STYLEREF to a heading holding a SEQ field,
|
||||
// and a REF to a bookmarked caption, show the new number.
|
||||
let seq = changed(doc, seq_results(doc));
|
||||
let mut out = (!seq.is_empty()).then(|| doc.clone());
|
||||
if let Some(d) = out.as_mut() {
|
||||
apply_results(d, seq).ok()?;
|
||||
}
|
||||
let styles = changed(out.as_ref().unwrap_or(doc), styleref_results(out.as_ref().unwrap_or(doc)));
|
||||
if !styles.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), styles).ok()?;
|
||||
}
|
||||
let refs = changed(out.as_ref().unwrap_or(doc), ref_results(out.as_ref().unwrap_or(doc), doc));
|
||||
if !refs.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), refs).ok()?;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`).
|
||||
fn has_switch(instr: &str, sw: char) -> bool {
|
||||
instr.split('\\').skip(1).any(|s| {
|
||||
@@ -181,7 +335,12 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
|
||||
// after one that sets a property shows the new value.
|
||||
let mut core = s.doc.core.clone();
|
||||
for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::<Vec<_>>() {
|
||||
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
|
||||
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
|
||||
// REF before the SET shows the last value set, as Word's bookmark does after an update.
|
||||
let last_set = set_values(s, &paths);
|
||||
let mut set: std::collections::HashMap<String, String> = Default::default();
|
||||
for (story, path) in paths {
|
||||
let Some(p) = s.doc.para(story, &path) else { continue };
|
||||
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
|
||||
continue;
|
||||
@@ -213,7 +372,14 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let f = FieldCode::parse(instr);
|
||||
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
|
||||
let r = if name == "REF" {
|
||||
if !has_switch(instr, 'p') { bookmark_text(&s.doc, target) } else { None }
|
||||
if has_switch(instr, 'p') {
|
||||
None
|
||||
} else {
|
||||
bookmark_text(&s.doc, target).or_else(|| {
|
||||
let key = target.to_lowercase();
|
||||
set.get(&key).or_else(|| last_set.get(&key)).cloned()
|
||||
})
|
||||
}
|
||||
} else if !has_switch(instr, 'p') {
|
||||
pages.get(target).map(|(num, page_fmt)| {
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt);
|
||||
@@ -226,6 +392,14 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
updates.push((k, if name == "REF" { text_result(instr, r) } else { r }));
|
||||
}
|
||||
}
|
||||
// A value given a name (the layout draws nothing for it; its result stays).
|
||||
"SET" => {
|
||||
if let Some((n, v)) = set_args(instr)
|
||||
&& set.len() < MAX_SET
|
||||
{
|
||||
set.insert(n, v);
|
||||
}
|
||||
}
|
||||
// The summary properties; a quoted text after the name replaces the property.
|
||||
"TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => {
|
||||
if let Some(v) = new_property_value(instr) {
|
||||
@@ -234,6 +408,16 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
updates.push((k, text_result(instr, summary_property(&core, &name).to_string())));
|
||||
}
|
||||
"LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))),
|
||||
// The user's name and initials (File › Options › General); a quoted text after
|
||||
// the name is shown instead.
|
||||
"USERNAME" | "USERINITIALS" => {
|
||||
let v = match new_property_value(instr) {
|
||||
Some(v) => v.to_string(),
|
||||
None if name == "USERNAME" => s.author.clone(),
|
||||
None => s.user_initials(),
|
||||
};
|
||||
updates.push((k, text_result(instr, v)));
|
||||
}
|
||||
"NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))),
|
||||
"NUMCHARS" => {
|
||||
let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count();
|
||||
@@ -316,3 +500,32 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most names SET fields give values.
|
||||
const MAX_SET: usize = 10_000;
|
||||
|
||||
/// A SET field's name (lower case: names are a bookmark's, which ignore case) and value.
|
||||
fn set_args(instr: &str) -> Option<(String, String)> {
|
||||
let f = FieldCode::parse(instr);
|
||||
let name = f.args.first().map(|n| n.trim()).filter(|n| !n.is_empty() && n.chars().count() <= 40)?;
|
||||
let value: String = f.args.get(1..).map(|v| v.join(" ")).unwrap_or_default().chars().take(MAX_SET).collect();
|
||||
Some((name.to_lowercase(), value))
|
||||
}
|
||||
|
||||
/// The value each name has after the last SET giving it one, in the stories at `paths`.
|
||||
fn set_values(s: &Session, paths: &[(StoryRef, Path)]) -> std::collections::HashMap<String, String> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for (story, path) in paths {
|
||||
let Some(p) = s.doc.para(*story, path) else { continue };
|
||||
for o in &p.objects {
|
||||
if let InlineObject::Field { instr, .. } = o
|
||||
&& wordcraft_layout::fields::field_name(instr) == "SET"
|
||||
&& let Some((n, v)) = set_args(instr)
|
||||
&& out.len() < MAX_SET
|
||||
{
|
||||
out.insert(n, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -92,11 +92,26 @@ pub fn date_picture(instr: &str) -> Option<String> {
|
||||
/// the paragraph's end. `None` when there is no such bookmark.
|
||||
pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
|
||||
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
bookmark_text_at(doc, name, &start)
|
||||
}
|
||||
|
||||
/// Does the bookmark `name` that starts at `start` end in the paragraph it starts in?
|
||||
pub(super) fn bookmark_ends_in_its_paragraph(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> bool {
|
||||
doc.para(start.story, &start.path).is_some_and(|para| {
|
||||
para.object_offsets()
|
||||
.into_iter()
|
||||
.any(|o| o > start.off && matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`bookmark_text`] for the bookmark `name` that starts at `start`, for a caller that has
|
||||
/// looked the document's bookmarks up once.
|
||||
pub(super) fn bookmark_text_at(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> Option<String> {
|
||||
let para = doc.para(start.story, &start.path)?;
|
||||
let end = para
|
||||
.object_offsets()
|
||||
.into_iter()
|
||||
.find(|o| *o > start.off && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
.unwrap_or(para.len());
|
||||
Some(doc.copy_range(&start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
Some(doc.copy_range(start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
}
|
||||
|
||||
@@ -327,6 +327,42 @@ fn document_information_fields_show_the_properties() {
|
||||
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
|
||||
}
|
||||
|
||||
/// SET gives a name a value (and is drawn as nothing); REF shows it, a REF before the SET the value it
|
||||
/// ends up with (an ODT's variables open as SET and REF).
|
||||
#[test]
|
||||
fn set_gives_a_value_that_ref_shows() {
|
||||
let doc = wordcraft_doc::Document::from_text("one");
|
||||
let codes = [
|
||||
r#"SET who "Ada Lovelace""#,
|
||||
"REF who",
|
||||
r"REF WHO \* Upper",
|
||||
"REF later",
|
||||
"SET later Bob",
|
||||
r#"SET who "Grace""#,
|
||||
"REF who",
|
||||
"SET",
|
||||
"REF nothing",
|
||||
];
|
||||
let (mut s, hdr) = header_fields_session(doc, &codes);
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!([&got[1], &got[2], &got[3], &got[6]], ["Ada Lovelace", "ADA LOVELACE", "Bob", "Grace"]);
|
||||
}
|
||||
|
||||
/// USERNAME and USERINITIALS show the user's name and initials from File › Options (an ODT's
|
||||
/// `text:author-name` opens as USERNAME); a quoted text after the name is shown instead.
|
||||
#[test]
|
||||
fn user_name_fields_show_the_users_name() {
|
||||
let doc = wordcraft_doc::Document::from_text("one");
|
||||
let codes = ["USERNAME", "USERINITIALS", r"USERNAME \* Upper", r#"USERNAME "Someone Else""#];
|
||||
let (mut s, hdr) = header_fields_session(doc, &codes);
|
||||
s.author = "Ada Lovelace".into();
|
||||
s.initials = String::new();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
|
||||
assert_eq!(got, ["Ada Lovelace", "AL", "ADA LOVELACE", "Someone Else"]);
|
||||
}
|
||||
|
||||
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
|
||||
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
|
||||
#[test]
|
||||
@@ -511,3 +547,353 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
|
||||
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
|
||||
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
|
||||
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
|
||||
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
|
||||
/// editor and the document keep every stored result.
|
||||
#[test]
|
||||
fn output_shows_fresh_seq_styleref_and_ref_results() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words"));
|
||||
s.run("select.text", &json!({"text": "Chapter One"})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Target words"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for (instr, result) in [
|
||||
(r#"SEQ Figure \* ARABIC"#, "7"),
|
||||
(r#"STYLEREF "Heading 1""#, "stale style"),
|
||||
("REF Mark \\h", "stale text"),
|
||||
("NUMWORDS", "999"),
|
||||
(r#"SEQ Table \* ARABIC"#, "9"),
|
||||
] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
// Stale stored results (inserting a field computes its result), the last field locked.
|
||||
let stored = ["7", "stale style", "stale text", "999", "9"];
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
let mut stale = stored.iter();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*result = stale.next().unwrap().to_string();
|
||||
*locked = stale.len() == 0;
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages
|
||||
.iter()
|
||||
.flat_map(|pg| &pg.items)
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]);
|
||||
assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), stored, "the document keeps them");
|
||||
let pdf = s.print_pdf(&json!({})).unwrap().0;
|
||||
assert!(pdf.len() > 1000);
|
||||
// Once the fields are current, output copies nothing.
|
||||
update_seq(&mut s).unwrap();
|
||||
update_fields(&mut s).unwrap();
|
||||
assert!(output_fields(&s.doc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
|
||||
// Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text
|
||||
// box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E),
|
||||
// and a REF to a bookmark in a box shows the box's text as stored.
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#;
|
||||
let run = |t: &str| format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#);
|
||||
let field = |instr: &str, stored: &str| {
|
||||
format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> {instr} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
||||
run(stored)
|
||||
)
|
||||
};
|
||||
let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9"));
|
||||
let text_box = |inner: String| {
|
||||
format!(
|
||||
r##"<w:r><w:pict><v:shape type="#_x0000_t202" style="position:absolute;margin-left:300pt;width:150pt;height:30pt"><v:textbox><w:txbxContent><w:p>{inner}</w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"##
|
||||
)
|
||||
};
|
||||
let marked = format!(r#"<w:bookmarkStart w:id="1" w:name="boxmark"/>{}<w:bookmarkEnd w:id="1"/>"#, seq("B"));
|
||||
let paras = [
|
||||
seq("A"),
|
||||
format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")),
|
||||
seq("D"),
|
||||
format!("{}{}", seq("E"), text_box(seq("F"))),
|
||||
seq("G"),
|
||||
format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")),
|
||||
];
|
||||
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
|
||||
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
|
||||
// The SEQ and REF results in reading order, text boxes where they are anchored.
|
||||
let mut results = Vec::new();
|
||||
out.objects_in_reading_order(&mut |o| {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone()));
|
||||
}
|
||||
});
|
||||
let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}");
|
||||
let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(refs, ["B Fig 9"], "{results:?}");
|
||||
}
|
||||
|
||||
/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or
|
||||
/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each
|
||||
/// REF stored as "stale"), rather than Word's missing-bookmark error.
|
||||
#[test]
|
||||
fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
let mark = |name: &str, id: u32| {
|
||||
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/><w:r><w:t>Target {name}</w:t></w:r><w:bookmarkEnd w:id="{id}"/>"#)
|
||||
};
|
||||
let field = |name: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> REF {name} \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>stale</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let names = ["HDR", "FTR", "FN", "EN", "CMT"];
|
||||
let refs: String = names.iter().map(|n| field(n)).collect();
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body>{refs}<w:p><w:r><w:t>Body </w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {ns}><w:p>{}</w:p></w:hdr>"#, mark("HDR", 1));
|
||||
let footer = format!(r#"<w:ftr {ns}><w:p>{}</w:p></w:ftr>"#, mark("FTR", 2));
|
||||
let footnotes =
|
||||
format!(r#"<w:footnotes {ns}><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>{}</w:p></w:footnote></w:footnotes>"#, mark("FN", 3));
|
||||
let endnotes = format!(r#"<w:endnotes {ns}><w:endnote w:id="1"><w:p><w:r><w:endnoteRef/></w:r>{}</w:p></w:endnote></w:endnotes>"#, mark("EN", 4));
|
||||
let comments = format!(r#"<w:comments {ns}><w:comment w:id="0" w:author="probe"><w:p>{}</w:p></w:comment></w:comments>"#, mark("CMT", 5));
|
||||
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{rel}/header" Target="header1.xml"/><Relationship Id="rIdF" Type="{rel}/footer" Target="footer1.xml"/><Relationship Id="rIdN" Type="{rel}/footnotes" Target="footnotes.xml"/><Relationship Id="rIdE" Type="{rel}/endnotes" Target="endnotes.xml"/><Relationship Id="rIdC" Type="{rel}/comments" Target="comments.xml"/></Relationships>"#
|
||||
);
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let files = [
|
||||
("_rels/.rels", root.to_string()),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document),
|
||||
("word/header1.xml", header),
|
||||
("word/footer1.xml", footer),
|
||||
("word/footnotes.xml", footnotes),
|
||||
("word/endnotes.xml", endnotes),
|
||||
("word/comments.xml", comments),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in &files {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
|
||||
let mut results = Vec::new();
|
||||
for path in out.para_paths(StoryRef::Body) {
|
||||
for o in &out.para(StoryRef::Body, &path).unwrap().objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
results.push(result.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected: Vec<String> = names.iter().map(|n| format!("Target {n}")).collect();
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
#[test]
|
||||
fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("See "));
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]);
|
||||
assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), ["stale", "stale"], "the document keeps it");
|
||||
}
|
||||
|
||||
/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't
|
||||
/// compute, keeps its stored number when printed or exported rather than show the paragraph's
|
||||
/// text; a plain REF to the same bookmark is filled in.
|
||||
#[test]
|
||||
fn output_keeps_paragraph_number_references_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Target heading"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"];
|
||||
for instr in instrs {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "2.1".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the plain REF changes");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]);
|
||||
}
|
||||
|
||||
/// The results of the fields in the paragraph at `path` of `d`.
|
||||
fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec<String> {
|
||||
d.para(StoryRef::Body, path)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path.
|
||||
fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path {
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = stale.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
last
|
||||
}
|
||||
|
||||
/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result
|
||||
/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to
|
||||
/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result
|
||||
/// rather than show the first paragraph's part alone.
|
||||
#[test]
|
||||
fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs "));
|
||||
let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) };
|
||||
s.run("insert.bookmark", &json!({"name": "Span"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Short mark"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Short"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF Span \\h", "REF Short \\h"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = stale_last_paragraph(&mut s, "stored");
|
||||
let out = output_fields(&s.doc).expect("the short bookmark's REF changes");
|
||||
assert_eq!(results_in(&out, &last), ["stored", "Short mark"]);
|
||||
}
|
||||
|
||||
/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number,
|
||||
/// in output and after F9: the numbers are filled in before the style references read them.
|
||||
#[test]
|
||||
fn a_style_reference_reads_the_headings_new_sequence_number() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Chapter "})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 });
|
||||
s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap();
|
||||
// Stale stored results: the heading's number 9, the reference "stale".
|
||||
let heading = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "9".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let last = stale_last_paragraph(&mut s, "stale");
|
||||
let out = output_fields(&s.doc).expect("the fields change");
|
||||
assert_eq!(results_in(&out, &heading), ["1"]);
|
||||
assert_eq!(results_in(&out, &last), ["Chapter 1"]);
|
||||
update_seq(&mut s).unwrap();
|
||||
assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too");
|
||||
}
|
||||
|
||||
/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one
|
||||
/// bookmark, fields to others, and one to a bookmark the document lacks each show what
|
||||
/// `bookmark_text` (F9's lookup) gives, or Word's error.
|
||||
#[test]
|
||||
fn output_gives_every_reference_its_bookmarks_text() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs "));
|
||||
for (text, name) in [("First target", "A"), ("Second target", "B")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let targets = ["A", "B", "A", "Missing", "A"];
|
||||
for t in targets {
|
||||
s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let expected: Vec<String> = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect();
|
||||
assert_eq!(results, expected);
|
||||
assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_new(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(true) {
|
||||
apply(s, &id)?;
|
||||
@@ -131,7 +131,7 @@ fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_modified(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
@@ -189,7 +189,7 @@ pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
|
||||
build_new(s, &v).ok()?
|
||||
};
|
||||
let id = st.id.clone();
|
||||
let mut sheet = s.doc.styles.clone();
|
||||
let mut sheet = (*s.doc.styles).clone();
|
||||
sheet.upsert(st);
|
||||
sheet.table_style(&id)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut rebased = Vec::new();
|
||||
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
for child in Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
let mut para = st.para.clone();
|
||||
para.overlay(&child.para);
|
||||
child.para = para;
|
||||
@@ -231,7 +231,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
child.based_on = st.based_on.clone();
|
||||
rebased.push(child.id.clone());
|
||||
}
|
||||
s.doc.styles.styles.retain(|x| x.id != id);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.retain(|x| x.id != id);
|
||||
let fallback = table_style_id(s, "TableGrid");
|
||||
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::Document;
|
||||
@@ -100,8 +101,8 @@ fn update_styles(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
/// What [`update_styles`] does to a document: all of `src`'s styles and its defaults.
|
||||
pub fn apply_template_styles(doc: &mut Document, src: &Document) -> Value {
|
||||
doc.styles.default_chr = src.styles.default_chr.clone();
|
||||
doc.styles.default_para = src.styles.default_para.clone();
|
||||
Arc::make_mut(&mut doc.styles).default_chr = src.styles.default_chr.clone();
|
||||
Arc::make_mut(&mut doc.styles).default_para = src.styles.default_para.clone();
|
||||
let ids: Vec<String> = src.styles.styles.iter().map(|st| st.id.clone()).collect();
|
||||
copy_styles(doc, src, &ids)
|
||||
}
|
||||
@@ -172,7 +173,7 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if s.doc.styles.styles.iter().any(|o| o.id != st.id && o.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
|
||||
}
|
||||
if let Some(x) = s.doc.styles.get_mut(&st.id) {
|
||||
if let Some(x) = Arc::make_mut(&mut s.doc.styles).get_mut(&st.id) {
|
||||
x.name = name.clone();
|
||||
}
|
||||
touch_all(s);
|
||||
@@ -346,7 +347,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
|
||||
}
|
||||
};
|
||||
let builtin = doc.styles.get(&target).is_some_and(|d| d.builtin) || st.builtin;
|
||||
doc.styles.upsert(Style { id: target.clone(), builtin, ..st.clone() });
|
||||
Arc::make_mut(&mut doc.styles).upsert(Style { id: target.clone(), builtin, ..st.clone() });
|
||||
map.insert(st.id.clone(), target.clone());
|
||||
order.push((st.id.clone(), target));
|
||||
}
|
||||
@@ -366,7 +367,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
|
||||
}
|
||||
n
|
||||
});
|
||||
if let Some(d) = doc.styles.get_mut(&target) {
|
||||
if let Some(d) = Arc::make_mut(&mut doc.styles).get_mut(&target) {
|
||||
d.based_on = based_on;
|
||||
d.next = next;
|
||||
d.linked = linked;
|
||||
@@ -389,8 +390,8 @@ fn import_num(doc: &mut Document, src: &Document, num: u32, map: &HashMap<String
|
||||
for l in &mut levels {
|
||||
l.style = l.style.as_deref().and_then(|s| map.get(s).cloned().or_else(|| doc.styles.get(s).map(|x| x.id.clone())));
|
||||
}
|
||||
doc.numbering.abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
|
||||
doc.numbering.nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
|
||||
Arc::make_mut(&mut doc.numbering).abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
|
||||
Arc::make_mut(&mut doc.numbering).nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
|
||||
done.insert(num, new_num);
|
||||
Some(new_num)
|
||||
}
|
||||
@@ -405,17 +406,17 @@ mod tests {
|
||||
/// "Box" (missing in the document) and a numbered "Steps" style.
|
||||
fn template() -> Document {
|
||||
let mut t = Document::new();
|
||||
if let Some(h) = t.styles.get_mut("Heading1") {
|
||||
if let Some(h) = Arc::make_mut(&mut t.styles).get_mut("Heading1") {
|
||||
h.chr.size = Some(31.0);
|
||||
}
|
||||
t.styles.upsert(Style {
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
id: "Box".into(),
|
||||
name: "Box".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
based_on: Some("Normal".into()),
|
||||
..Default::default()
|
||||
});
|
||||
t.styles.upsert(Style {
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
id: "Callout".into(),
|
||||
name: "Callout".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -423,8 +424,8 @@ mod tests {
|
||||
chr: CharProps { italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let num = t.numbering.add_list(ListKind::Numbered);
|
||||
t.styles.upsert(Style {
|
||||
let num = Arc::make_mut(&mut t.numbering).add_list(ListKind::Numbered);
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
id: "Steps".into(),
|
||||
name: "Steps".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::{
|
||||
Block, Border, BorderStyle, CmdError, CmdResult, ListKind, NumRef, OBJ, Path, Pos, Selection, Session, StoryRef, Table, Value, delete_selection,
|
||||
p, sel_result, split_para, type_text_effect,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Smart quotes and AutoFormat-as-you-type (lists from "* " / "1. ", dashes).
|
||||
fn autoformat(s: &mut Session, text: &str) -> Option<String> {
|
||||
@@ -164,7 +165,7 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None };
|
||||
let num = match found {
|
||||
Some(n) => n,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
};
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.delete(0, f.off)?;
|
||||
@@ -315,10 +316,11 @@ pub(super) fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
|
||||
return None;
|
||||
}
|
||||
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style), with
|
||||
// no outline level: resolving drops the body-text level 9 and above.
|
||||
let plain = |p: &wordcraft_doc::Paragraph| {
|
||||
let r = s.doc.styles.resolve_para(&p.props);
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none()
|
||||
};
|
||||
let text = prev.text.trim_start_matches('\t');
|
||||
let tabs = prev.text.len() - text.len();
|
||||
@@ -357,14 +359,8 @@ pub(super) fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result
|
||||
crate::cmd::para::set_heading(s, at.story, &prev, Some(level))?;
|
||||
// Removed tabs change the text, and the formatting comparison after the command only looks
|
||||
// at paragraphs that keep theirs, so the style change is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
|
||||
{
|
||||
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &prev)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
|
||||
p.touch();
|
||||
if s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text) {
|
||||
crate::cmd::fmt_revisions::record_para_props(s, at.story, &prev, &before.props)?;
|
||||
}
|
||||
s.sel = Selection::caret(at.clone());
|
||||
Ok(())
|
||||
@@ -398,14 +394,8 @@ pub(super) fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Resu
|
||||
p.touch();
|
||||
// The split changes the block count, and the formatting comparison after the command
|
||||
// skips a container whose block count changed, so the border is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
|
||||
{
|
||||
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
|
||||
p.touch();
|
||||
if let Some(before) = before {
|
||||
crate::cmd::fmt_revisions::record_para_props(s, at.story, &at.path, &before)?;
|
||||
}
|
||||
s.sel = Selection::caret(new);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
|
||||
|
||||
/// Options › Language › Set as default: only the saved default proofing language (what future
|
||||
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
|
||||
/// document, the selection, the undo history and the modified flag are left alone.
|
||||
pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let t = p::str(v, "lang").ok_or_else(|| CmdError::Params("`lang` is required".into()))?;
|
||||
let l = wordcraft_proof::lang::normalize_tag(t)
|
||||
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?;
|
||||
s.prefs.default_language = Some(l.clone());
|
||||
Ok(json!({"preferred": l}))
|
||||
}
|
||||
|
||||
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
|
||||
/// or (`default`) the document's default and the saved language for future blank documents;
|
||||
/// `detect` is the per-user detection preference.
|
||||
@@ -12,14 +23,6 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// Options › Language › Set as default: only the saved default proofing language (what future
|
||||
// blank documents are written in), as Word's Set as Preferred; neither this document nor
|
||||
// the selection changes.
|
||||
if p::bool(v, "preferredOnly") == Some(true) {
|
||||
let l = lang.ok_or_else(|| CmdError::Params("`preferredOnly` needs `lang`".into()))?;
|
||||
s.prefs.default_language = Some(l.clone());
|
||||
return Ok(json!({"preferred": l}));
|
||||
}
|
||||
let no_proof = p::bool(v, "noProof");
|
||||
let detect = p::bool(v, "detect");
|
||||
if let Some(d) = detect {
|
||||
@@ -27,9 +30,9 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
if p::bool(v, "default") == Some(true) {
|
||||
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".into()))?;
|
||||
s.doc.styles.default_chr.lang = Some(l.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some(l.clone());
|
||||
// A Normal style that names its own language follows the new default.
|
||||
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
|
||||
if let Some(n) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "Normal")
|
||||
&& n.chr.lang.is_some()
|
||||
{
|
||||
n.chr.lang = Some(l);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::actions::{customize_keyboard, customize_ribbon, hf_nav, language, macros, quick_parts, search_commands, select_similar};
|
||||
use super::actions::{
|
||||
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
||||
};
|
||||
use super::autocorrect::autocorrect_options;
|
||||
use super::compare::{COMPARE_PARAMS, compare};
|
||||
use super::inspect::{accessibility, inspect_doc};
|
||||
@@ -6,6 +8,7 @@ use super::{
|
||||
Block, CharProps, CmdError, CommandSpec, InlineObject, NumRef, Paragraph, Selection, StoryRef, Value, delete_selection, json, p, para_block,
|
||||
sel_result,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
|
||||
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
|
||||
@@ -297,8 +300,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(|x| x.props.numbering)
|
||||
.filter(|n| n.num != 0)
|
||||
.ok_or_else(|| CmdError::Failed("not in a numbered list".into()))?;
|
||||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
if let Some(num) = s.doc.numbering.nums.iter_mut().find(|x| x.id == new) {
|
||||
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
if let Some(num) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == new) {
|
||||
num.start_overrides = vec![(n.level, value)];
|
||||
}
|
||||
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|q| *q >= f.path).collect();
|
||||
@@ -321,9 +324,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal"));
|
||||
let text = p::str(v, "text").unwrap_or("%1.").to_string();
|
||||
let start = p::u64(v, "start").unwrap_or(1).min(100_000) as u32;
|
||||
let id = s.doc.numbering.try_add_list(wordcraft_doc::ListKind::Numbered)?;
|
||||
let id = Arc::make_mut(&mut s.doc.numbering).try_add_list(wordcraft_doc::ListKind::Numbered)?;
|
||||
if let Some(a) = s.doc.numbering.abstract_of(id).map(|a| a.id)
|
||||
&& let Some(abs) = s.doc.numbering.abstracts.iter_mut().find(|x| x.id == a)
|
||||
&& let Some(abs) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|x| x.id == a)
|
||||
&& let Some(l) = abs.levels.first_mut()
|
||||
{
|
||||
l.format = fmt;
|
||||
@@ -337,8 +340,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
|
||||
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents), "preferredOnly"?: bool (with `lang`: only the saved default proofing language for future blank documents, as Options › Language › Set as default does; the open document and the selection don't change)}"#,
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
|
||||
),
|
||||
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
|
||||
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
|
||||
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
|
||||
s.relayout();
|
||||
|
||||
@@ -177,11 +177,11 @@ fn full_screen_available(_: &Session) -> Option<&'static str> {
|
||||
|
||||
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
if read && !s.view.read_mode {
|
||||
// Reading controls are session-only, and reset on each entry to the view.
|
||||
// Width, size and spacing reset on each entry. Page Color follows the saved preference.
|
||||
s.view.read_column_width = None;
|
||||
s.view.read_zoom = 1.0;
|
||||
s.view.read_line_spacing = None;
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
s.view.read_page_color = s.prefs.read_page_color;
|
||||
}
|
||||
s.view.mode = m;
|
||||
s.view.read_mode = read;
|
||||
@@ -256,6 +256,7 @@ fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
|
||||
};
|
||||
s.view.read_page_color = color;
|
||||
s.prefs.read_page_color = color;
|
||||
Ok(json!({"pageColor": color}))
|
||||
}
|
||||
|
||||
|
||||
+66
-16
@@ -1,6 +1,7 @@
|
||||
//! File format dispatch by extension. Format crates plug in here.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::odf_profile::OdfProfile;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
@@ -22,6 +23,8 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"mhtml",
|
||||
"rtf",
|
||||
"odt",
|
||||
"ott",
|
||||
"fodt",
|
||||
"tex",
|
||||
"latex",
|
||||
"ltx",
|
||||
@@ -30,7 +33,8 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"json",
|
||||
];
|
||||
/// Formats WordCraft saves (Save As).
|
||||
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"];
|
||||
pub const SAVE_EXTS: &[&str] =
|
||||
&["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "ott", "fodt", "tex", "png", "json", "xml"];
|
||||
|
||||
/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm).
|
||||
const ZIP_SIG: &[u8] = b"PK\x03\x04";
|
||||
@@ -129,9 +133,25 @@ pub(crate) fn canonical(_path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Can files with this extension be saved with a password (Word's packages)?
|
||||
/// Can files with this extension be saved with a password (Word's packages, ODT)?
|
||||
pub fn can_encrypt(name: &str) -> bool {
|
||||
is_word_package(&ext_of(name))
|
||||
let ext = ext_of(name);
|
||||
is_word_package(&ext) || ext == "odt" || ext == "ott"
|
||||
}
|
||||
|
||||
/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or
|
||||
/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`]
|
||||
/// recognise them.
|
||||
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps, odf: &OdfProfile) -> Result<Document, String> {
|
||||
let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?;
|
||||
let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e {
|
||||
wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD),
|
||||
other => other.to_string(),
|
||||
})?;
|
||||
match crate::io_ext::open("odt", &plain, page, odf) {
|
||||
Some(r) => r,
|
||||
None => Err("ODT: no reader".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a document just read from a file: never leave it empty, and bring its unlocked
|
||||
@@ -168,7 +188,7 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
|
||||
/// whether the file was encrypted. Without the password needed the error satisfies
|
||||
/// [`needs_password`]; with a wrong one, [`wrong_password`].
|
||||
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default())
|
||||
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
|
||||
}
|
||||
|
||||
/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s
|
||||
@@ -179,10 +199,17 @@ pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionPro
|
||||
}
|
||||
|
||||
/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper
|
||||
/// (see [`region_page`]); a document with its own page size keeps it.
|
||||
pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
/// (see [`region_page`]); a document with its own page size keeps it. An OpenDocument file fills
|
||||
/// in what its standard leaves open as `odf` says (the user's choice, [`crate::Prefs::odf`]).
|
||||
pub fn open_bytes_in(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
region: &wordcraft_geom::Region,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let page = region_page(region);
|
||||
let (doc, encrypted) = read_bytes(name, bytes, password, &page)?;
|
||||
let (doc, encrypted) = read_bytes(name, bytes, password, &page, odf)?;
|
||||
load_embedded_fonts(&doc);
|
||||
Ok((doc, encrypted))
|
||||
}
|
||||
@@ -200,13 +227,24 @@ fn load_embedded_fonts(doc: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> {
|
||||
fn read_bytes(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let ext = ext_of(name);
|
||||
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
|
||||
let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?;
|
||||
opened(&mut doc);
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) {
|
||||
let mut doc = open_encrypted_odt(bytes, password, page, odf)?;
|
||||
opened(&mut doc);
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if ext.is_empty() || signature_contradicts(&ext, bytes) {
|
||||
return open_by_content(name, &ext, bytes, password, page);
|
||||
}
|
||||
@@ -223,7 +261,7 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
d.normalize_runs();
|
||||
d
|
||||
}
|
||||
other => match crate::io_ext::open(other, bytes, page) {
|
||||
other => match crate::io_ext::open(other, bytes, page, odf) {
|
||||
Some(r) => r?,
|
||||
None => return open_by_content(name, other, bytes, password, page),
|
||||
},
|
||||
@@ -397,10 +435,11 @@ fn open_by_content(
|
||||
} else if is_word_xml(bytes) {
|
||||
(wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false)
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) {
|
||||
let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
|
||||
let opened = crate::io_ext::open("html", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
|
||||
(opened?, false)
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) {
|
||||
let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
|
||||
let opened =
|
||||
crate::io_ext::open("mht", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
|
||||
(opened?, false)
|
||||
} else if wordcraft_formats::txt::is_text(bytes) {
|
||||
(wordcraft_formats::txt::import_on(bytes, page), false)
|
||||
@@ -440,6 +479,7 @@ pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Resu
|
||||
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||
let bytes = save_bytes_opts(name, doc, opts)?;
|
||||
match password {
|
||||
Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw),
|
||||
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
|
||||
_ => Ok(bytes),
|
||||
}
|
||||
@@ -452,12 +492,17 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
|
||||
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
|
||||
open_path_in(path, password, &wordcraft_geom::Region::default())
|
||||
open_path_in(path, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
|
||||
}
|
||||
|
||||
/// [`open_path_with`] in `region` (see [`open_bytes_in`]).
|
||||
/// [`open_path_with`] in `region`, OpenDocument files as `odf` says (see [`open_bytes_in`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
pub fn open_path_in(
|
||||
path: &std::path::Path,
|
||||
password: Option<&str>,
|
||||
region: &wordcraft_geom::Region,
|
||||
odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let bytes = read_document(path)?;
|
||||
let name = path.to_string_lossy();
|
||||
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
|
||||
@@ -469,7 +514,7 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor
|
||||
opened(&mut doc);
|
||||
return Ok((doc, false));
|
||||
}
|
||||
open_bytes_in(&name, &bytes, password, region)
|
||||
open_bytes_in(&name, &bytes, password, region, odf)
|
||||
}
|
||||
|
||||
/// Largest document file opened by path.
|
||||
@@ -806,7 +851,12 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
|
||||
pub fn open_path_in(
|
||||
path: &std::path::Path,
|
||||
password: Option<&str>,
|
||||
_region: &wordcraft_geom::Region,
|
||||
_odf: &OdfProfile,
|
||||
) -> Result<(Document, bool), String> {
|
||||
open_path_with(path, password)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,53 @@ use wordcraft_doc::Document;
|
||||
|
||||
/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML)
|
||||
/// start on `page`.
|
||||
pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
|
||||
pub fn open(
|
||||
ext: &str,
|
||||
bytes: &[u8],
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
odf: &wordcraft_doc::odf_profile::OdfProfile,
|
||||
) -> Option<Result<Document, String>> {
|
||||
match ext {
|
||||
"docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())),
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
|
||||
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
||||
other => wordcraft_formats::import_with(other, bytes, page, odf).map(|r| {
|
||||
r.map(|mut doc| {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
draw_charts(&mut doc);
|
||||
doc
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the
|
||||
/// document's theme colours, at their size.
|
||||
fn draw_charts(doc: &mut Document) {
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
/// Groups nest at most this deep here (the readers flatten deeper ones).
|
||||
const DEPTH: usize = 16;
|
||||
fn undrawn(o: &InlineObject, depth: usize) -> bool {
|
||||
match o {
|
||||
InlineObject::Graphic { graphic, .. } => graphic.chart.is_some() && graphic.items.is_empty(),
|
||||
InlineObject::Group { children, .. } if depth < DEPTH => children.iter().any(|c| undrawn(&c.obj, depth + 1)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn draw(o: &mut InlineObject, theme: &[wordcraft_doc::Rgb], depth: usize) {
|
||||
match o {
|
||||
InlineObject::Group { children, .. } if depth < DEPTH => children.iter_mut().for_each(|c| draw(&mut c.obj, theme, depth + 1)),
|
||||
o if undrawn(o, depth) => crate::cmd::chart::redraw(o, theme),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let theme = doc.settings.theme_colors.clone();
|
||||
// (Drawing them doesn't change the document: markup kept as read for them still is.)
|
||||
doc.fill_objects(&|o| undrawn(o, 0), &mut |o| draw(o, &theme, 0));
|
||||
}
|
||||
|
||||
/// Save to a format other than plain text / JSON.
|
||||
pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option<Result<Vec<u8>, String>> {
|
||||
// .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects.
|
||||
@@ -31,6 +62,9 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0, opts)),
|
||||
"pdf" => {
|
||||
// Output shows the fields Word fills in as it prints (see `output_fields`).
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let pdf = wordcraft_pdf::PdfOptions {
|
||||
include_markup: opts.markup,
|
||||
comments: opts.markup && opts.comments,
|
||||
@@ -46,6 +80,8 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
|
||||
/// markup area with the comments in balloons (page images always show tracked changes).
|
||||
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
|
||||
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
|
||||
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
|
||||
+18
-15
@@ -589,6 +589,20 @@ pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
|
||||
pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c))
|
||||
}
|
||||
|
||||
/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or
|
||||
/// three digits (`419`)? The one test of what a region is; the lists, the menus and
|
||||
/// [`pack_for_tag`] all use it.
|
||||
pub fn is_region(subtag: &str) -> bool {
|
||||
(subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia`
|
||||
/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept.
|
||||
/// The language itself (the first subtag) is never a region.
|
||||
pub fn without_region(tag: &str) -> String {
|
||||
tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::<Vec<&str>>().join("-")
|
||||
}
|
||||
|
||||
/// The pack locales [`pack_for_tag`] tries for `tag`, best first.
|
||||
fn pack_candidates(tag: &str) -> Vec<String> {
|
||||
// A tag is a handful of short subtags; anything longer is junk and only its start is read.
|
||||
@@ -598,23 +612,12 @@ fn pack_candidates(tag: &str) -> Vec<String> {
|
||||
if base.is_empty() || base == "en" {
|
||||
return Vec::new();
|
||||
}
|
||||
let region = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(1)
|
||||
.find(|(_, p)| (p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic())) || (p.len() == 3 && p.bytes().all(|b| b.is_ascii_digit())));
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for n in (1..=parts.len()).rev() {
|
||||
let prefix = parts.get(..n).unwrap_or_default();
|
||||
for c in [
|
||||
Some(prefix.join("-")),
|
||||
region
|
||||
.filter(|(r, _)| r + 1 < n)
|
||||
.map(|(r, _)| prefix.iter().enumerate().filter(|(i, _)| *i != r).map(|(_, p)| *p).collect::<Vec<&str>>().join("-")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let prefix = parts.get(..n).unwrap_or_default().join("-");
|
||||
// The prefix, then without its region: the variant's pack before the region alone.
|
||||
let stripped = without_region(&prefix);
|
||||
for c in [prefix, stripped] {
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub struct Prefs {
|
||||
/// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system.
|
||||
pub region: Option<String>,
|
||||
/// A folder searched for Hunspell dictionaries before the system locations (File › Options ›
|
||||
/// Proofing, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// Language, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only.
|
||||
pub dictionaries_folder: Option<String>,
|
||||
/// The user templates folder File › New lists personal templates from and Save as Word
|
||||
@@ -49,6 +49,9 @@ pub struct Prefs {
|
||||
/// Check spelling as you type (File › Options › Proofing). The live value is
|
||||
/// `ViewState::proofing`; this is the copy that is saved and restored (#1792).
|
||||
pub check_spelling: bool,
|
||||
/// Read Mode's Page Color. The live value is `ViewState::read_page_color`.
|
||||
#[serde(deserialize_with = "lenient_read_page_color")]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub ignore_uppercase: bool,
|
||||
pub ignore_numbers: bool,
|
||||
pub ignore_internet: bool,
|
||||
@@ -56,6 +59,17 @@ pub struct Prefs {
|
||||
/// in Word). A damaged saved value reads as the defaults.
|
||||
#[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")]
|
||||
pub autocorrect: crate::cmd::tools::AutoCorrectPrefs,
|
||||
/// How OpenDocument files fill in what their standard leaves to the application: WordCraft's
|
||||
/// choices, LibreOffice's, or the user's own (File › Options › Advanced, `file.options`'s
|
||||
/// `odf`; see `docs/odf-interpretation.md`). A damaged saved value reads as WordCraft's.
|
||||
#[serde(deserialize_with = "lenient_odf")]
|
||||
pub odf: wordcraft_doc::odf_profile::OdfProfile,
|
||||
}
|
||||
|
||||
/// A saved ODF profile that doesn't read (an older or damaged one) is WordCraft's.
|
||||
fn lenient_odf<'de, D: serde::Deserializer<'de>>(d: D) -> Result<wordcraft_doc::odf_profile::OdfProfile, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(v).unwrap_or_default())
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -74,12 +88,14 @@ impl Default for Prefs {
|
||||
marks: Default::default(),
|
||||
mark_grammar: true,
|
||||
check_spelling: true,
|
||||
read_page_color: Default::default(),
|
||||
ignore_uppercase: true,
|
||||
ignore_numbers: true,
|
||||
ignore_internet: true,
|
||||
detect_language: true,
|
||||
default_language: None,
|
||||
autocorrect: Default::default(),
|
||||
odf: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,3 +116,30 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
|
||||
}
|
||||
|
||||
/// Ignore an invalid saved Page Color without discarding other preferences.
|
||||
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(value).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Prefs;
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
|
||||
#[test]
|
||||
fn read_page_color_loads_from_old_and_damaged_prefs() {
|
||||
let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap();
|
||||
assert_eq!(old.read_page_color, ReadPageColor::None);
|
||||
assert!(!old.check_spelling);
|
||||
for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] {
|
||||
let prefs: Prefs = serde_json::from_str(bad).unwrap();
|
||||
assert_eq!(prefs.read_page_color, ReadPageColor::None);
|
||||
assert!(!prefs.check_spelling);
|
||||
}
|
||||
let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap();
|
||||
assert_eq!(saved.read_page_color, ReadPageColor::Sepia);
|
||||
assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings
|
||||
//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies
|
||||
//! and collation. Printer choice, duplex and pages per sheet belong to the OS print dialog.
|
||||
//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex
|
||||
//! are handled by the host.
|
||||
//!
|
||||
//! Page ranges and copies come from users and control channels, so they are bounded: a range
|
||||
//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the
|
||||
@@ -10,6 +11,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_layout::DocLayout;
|
||||
pub use wordcraft_pdf::imposition::Imposition;
|
||||
|
||||
use crate::{CmdError, Session, p};
|
||||
|
||||
@@ -146,6 +148,47 @@ pub fn paper(v: &Value) -> Result<Option<(&'static str, f32, f32)>, CmdError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the physical sheet layout without touching document page setup.
|
||||
pub fn imposition(v: &Value) -> Result<wordcraft_pdf::imposition::Imposition, CmdError> {
|
||||
match v.get("pagesPerSheet") {
|
||||
None => Ok(Imposition::Single),
|
||||
Some(Value::Number(n)) => match n.as_u64() {
|
||||
Some(1) => Ok(Imposition::Single),
|
||||
Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)),
|
||||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||||
},
|
||||
Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }),
|
||||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy.
|
||||
fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result<Option<(f32, f32)>, CmdError> {
|
||||
let paper = paper(v)?.map(|(_, w, h)| (w, h));
|
||||
if imposition(v)? == Imposition::Single {
|
||||
return Ok(paper);
|
||||
}
|
||||
let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h));
|
||||
let Some((w, h)) = paper.or(own) else { return Ok(paper) };
|
||||
match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") {
|
||||
"auto" => Ok(paper),
|
||||
"portrait" => Ok(Some((w.min(h), w.max(h)))),
|
||||
"landscape" => Ok(Some((w.max(h), w.min(h)))),
|
||||
_ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan physical sheet sides for one copy, using the same selection and geometry as printing.
|
||||
pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result<Vec<wordcraft_pdf::imposition::Sheet>, CmdError> {
|
||||
if !v.is_object() {
|
||||
return Err(CmdError::Params("print settings must be an object".into()));
|
||||
}
|
||||
let mut single = v.clone();
|
||||
single["copies"] = json!(1);
|
||||
let pages = plan(&single, lay.pages.len(), current)?.pages;
|
||||
imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The pages the caret (or selection) is on, in the output layout: (first, last).
|
||||
pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) {
|
||||
@@ -169,14 +212,26 @@ impl Session {
|
||||
self.print_pdf_with(v, false)
|
||||
}
|
||||
|
||||
/// Render a shared sheet plan for a saved PDF or an annotation-free printer job.
|
||||
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||||
if !v.is_object() {
|
||||
return Err(CmdError::Params("print settings must be an object".into()));
|
||||
}
|
||||
// Print Markup (File › Print) decides whether tracked changes and comments print.
|
||||
let markup = crate::cmd::file::export_options(self, v);
|
||||
let lay = self.export_layout_with(markup.markup);
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
let doc = out.as_ref().unwrap_or(&self.doc);
|
||||
let lay = self.export_layout_of(doc, markup.markup);
|
||||
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
|
||||
let paper = paper(v)?.map(|(_, w, h)| (w, h));
|
||||
let mut single = v.clone();
|
||||
single["copies"] = json!(1);
|
||||
let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?;
|
||||
let paper = imposed_paper(v, &lay, &single_plan.pages)?;
|
||||
let opts = wordcraft_pdf::PdfOptions {
|
||||
pages: Some(plan.pages.clone()),
|
||||
pages: Some(single_plan.pages),
|
||||
imposition: imposition(v)?,
|
||||
sheet_copies: plan.copies,
|
||||
sheet_collate: p::bool(v, "collate").unwrap_or(true),
|
||||
// Repeated pages would repeat the structure tree's entries too.
|
||||
tagged: plan.copies <= 1,
|
||||
include_markup: markup.markup,
|
||||
@@ -185,7 +240,7 @@ impl Session {
|
||||
links,
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
Ok((bytes, plan))
|
||||
}
|
||||
}
|
||||
@@ -340,3 +395,31 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod imposition_tests {
|
||||
use super::*;
|
||||
/// Sheet printing leaves document bytes, dirty state, selection and undo history untouched.
|
||||
#[test]
|
||||
fn print_imposition_is_pure_and_rejects_hostile_modes() {
|
||||
let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five"));
|
||||
let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap());
|
||||
for (mode, sides) in
|
||||
[(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)]
|
||||
{
|
||||
let params = json!({"pagesPerSheet":mode, "copies":2});
|
||||
let lay = s.export_layout();
|
||||
assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides);
|
||||
let (bytes, plan) = s.print_pdf(¶ms).unwrap();
|
||||
assert_eq!(plan.copies, 2);
|
||||
assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2);
|
||||
}
|
||||
for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] {
|
||||
assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err());
|
||||
}
|
||||
for params in [json!(null), json!([]), json!(false)] {
|
||||
assert!(s.print_pdf(¶ms).is_err());
|
||||
}
|
||||
assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Built-in sample documents and templates (original text, generated in code).
|
||||
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor};
|
||||
use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block};
|
||||
@@ -50,10 +51,10 @@ pub fn sample_document() -> Document {
|
||||
quote.props.space_before = Some(12.0);
|
||||
b.push(Block::Para(quote));
|
||||
b.push(Block::Para(styled("What we agree on", "Heading2")));
|
||||
let mut num = d.numbering.clone();
|
||||
let mut num = (*d.numbering).clone();
|
||||
let bullets = num.add_list(ListKind::Bullet);
|
||||
let steps = num.add_list(ListKind::Numbered);
|
||||
d.numbering = num;
|
||||
d.numbering = Arc::new(num);
|
||||
for (lead, rest) in [
|
||||
("Clean as you go. ", "Leave a table the way you would like to find it."),
|
||||
("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."),
|
||||
|
||||
@@ -385,6 +385,7 @@ impl Session {
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.others.push(d);
|
||||
Ok(())
|
||||
}
|
||||
@@ -459,6 +460,7 @@ impl Session {
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
view.read_page_color = self.prefs.read_page_color;
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
|
||||
@@ -161,8 +161,15 @@ impl Session {
|
||||
}
|
||||
|
||||
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
|
||||
/// area with the comments in balloons.
|
||||
/// area with the comments in balloons. Fields Word fills in for output show their fresh
|
||||
/// results ([`crate::cmd::references::output_fields`]).
|
||||
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup)
|
||||
}
|
||||
|
||||
/// [`Session::export_layout_with`] of `doc`, this document as it is output.
|
||||
pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc<DocLayout> {
|
||||
let (fn_name, fn_path) = self.resolved_filenames();
|
||||
let opts = LayoutOptions {
|
||||
view: ViewMode::Print,
|
||||
@@ -172,7 +179,7 @@ impl Session {
|
||||
filepath: Some(Arc::from(fn_path.as_str())),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
|
||||
Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts))
|
||||
}
|
||||
|
||||
/// Invalidate the cached layout (fonts changed etc.).
|
||||
@@ -199,7 +206,7 @@ impl Session {
|
||||
self.editing_lang = tag;
|
||||
let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty();
|
||||
if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 {
|
||||
self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
self.rev = self.rev.wrapping_add(1);
|
||||
// Not an edit: the document is as untouched as it was.
|
||||
if self.made_rev == self.rev.wrapping_sub(1) {
|
||||
|
||||
@@ -31,6 +31,7 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
||||
@@ -135,6 +135,7 @@ impl Session {
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
|
||||
+369
-36
@@ -4,6 +4,7 @@ use wordcraft_proof::cs::install_dictionary;
|
||||
mod czech_test_dict;
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -2570,19 +2571,22 @@ fn select_extend_from_a_table_paragraph_selects_the_document() {
|
||||
assert_eq!(b, s.doc.end_of(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table
|
||||
/// in the deepest cell says so; a table pasted there brings its text as paragraphs.
|
||||
/// #1596, #3618: tables nest only as deep as a `.json` save (and so an AutoRecover snapshot)
|
||||
/// reads back. Insert › Table in the deepest cell says so; a table pasted there brings its text
|
||||
/// as paragraphs.
|
||||
#[test]
|
||||
fn tables_nest_at_most_24_deep() {
|
||||
fn tables_nest_only_as_deep_as_a_json_save_reads() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err();
|
||||
assert!(e.to_string().contains("24 deep"), "{e}");
|
||||
assert!(e.to_string().contains(&format!("{MAX_TABLE_DEPTH} deep")), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
run(&mut s, "edit.paste", json!({"text": "b", "html": "<table><tr><td>b</td></tr></table>"}));
|
||||
let cell = s.sel.focus.path.clone();
|
||||
@@ -2591,6 +2595,44 @@ fn tables_nest_at_most_24_deep() {
|
||||
assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there");
|
||||
}
|
||||
|
||||
/// #3618: a text box story sits two JSON levels below the body, and its deepest cell still reads
|
||||
/// back from a `.json` save with the richest content measured pasted in: a gradient shape, and
|
||||
/// gradient text effects changed with Track Changes on (the change keeps the old effects too).
|
||||
#[test]
|
||||
fn the_deepest_cell_of_a_text_box_reads_back_with_rich_content() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
|
||||
run(&mut s, "shape.fill", json!({"gradient": {"stops": [{"pos": 0, "color": "FF0000"}, {"pos": 100, "color": "0000FF"}]}}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "word"}));
|
||||
run(&mut s, "select.text", json!({"text": "word"}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["FF0000", "0000FF"]}, "shadow": {"color": "FF0000"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": true}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["00FF00", "0000FF"]}, "glow": {"color": "00FF00"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": false}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "in box"}));
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_ne!(s.sel.focus.story, StoryRef::Body);
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
// Commands don't reach this deep (the paragraph walk stops at 16), so check the paste did.
|
||||
let para = s.doc.block(s.sel.focus.story, &s.sel.focus.path).and_then(|b| b.as_para()).unwrap();
|
||||
let gradient = |f: &Option<wordcraft_doc::fill::Fill>| matches!(f, Some(wordcraft_doc::fill::Fill::Gradient(_)));
|
||||
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Shape { fill, .. } if gradient(fill))), "{para:?}");
|
||||
assert!(
|
||||
para.runs.iter().any(|r| r.props.text_effects.is_some() && r.props.fmt_change.as_ref().is_some_and(|c| c.old.text_effects.is_some())),
|
||||
"{para:?}"
|
||||
);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
}
|
||||
|
||||
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
|
||||
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
|
||||
#[test]
|
||||
@@ -4356,7 +4398,7 @@ fn toc_page_numbers_reach_the_right_margin() {
|
||||
let long = "A deliberately long section title that does not fit on one line of the table of contents and wraps onto a second line";
|
||||
let mut d = wordcraft_doc::Document::from_text(&format!("Contents\n\nNo entries.\n\nShort\n{long}\nTop\nAppendix"));
|
||||
{
|
||||
let toc2 = d.styles.get_mut("TOC2").unwrap();
|
||||
let toc2 = Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap();
|
||||
toc2.para.tabs = Some(vec![
|
||||
TabStop { pos: 77.0, align: TabAlign::Left, leader: TabLeader::None },
|
||||
TabStop { pos: 467.2, align: TabAlign::Right, leader: TabLeader::Dot },
|
||||
@@ -4493,7 +4535,7 @@ fn toc_entries_carry_heading_numbers() {
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
let num = s.doc.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let num = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
for b in s.doc.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b)
|
||||
&& p.props.style.as_deref() == Some("Heading1")
|
||||
@@ -4593,13 +4635,41 @@ fn hostile_params_never_panic() {
|
||||
for j in &junk {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
assert_ne!(s.read_aloud.backend, crate::speech::Backend::System, "tests must never start real speech");
|
||||
let _ = s.run(spec.id, j);
|
||||
let r = s.run(spec.id, j);
|
||||
assert!(!caught_panic(®, &r), "{} panicked on {j}: {r:?}", spec.id);
|
||||
s.clamp_selection();
|
||||
let _ = s.layout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
|
||||
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
|
||||
/// those words.
|
||||
fn caught_panic(reg: &crate::Registry, r: &Result<serde_json::Value, crate::CmdError>) -> bool {
|
||||
matches!(r, Err(crate::CmdError::Failed(m)) if m.split_once(" panicked: ").is_some_and(|(id, _)| reg.get(id).is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caught_panic_flags_a_command_that_panics() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let boom = crate::CommandSpec::new("test.boom", "Boom", "", |_, _| panic!("boom")).pure();
|
||||
// Outline's commands run Paragraph ones: the inner command's name is in the error (review of #4207).
|
||||
let outer = crate::CommandSpec::new("test.outer", "Outer", "", |s, v| s.run("test.boom", v)).pure();
|
||||
s.registry = std::sync::Arc::new(crate::Registry::new(vec![boom, outer]));
|
||||
let boom = s.run("test.boom", &json!({}));
|
||||
assert!(caught_panic(&s.registry, &boom));
|
||||
let nested = s.run("test.outer", &json!({}));
|
||||
assert!(matches!(&nested, Err(crate::CmdError::Failed(m)) if m.starts_with("test.boom panicked: ")), "{nested:?}");
|
||||
assert!(caught_panic(&s.registry, &nested));
|
||||
assert!(!caught_panic(&s.registry, &Err(crate::CmdError::Failed("no picture".into()))));
|
||||
// A command's own error can quote what it was given.
|
||||
let mut full = Session::new(crate::sample::sample_document());
|
||||
let echoed = full.run("developer.control", &json!({"control": "x panicked: y"}));
|
||||
assert!(matches!(&echoed, Err(crate::CmdError::Failed(m)) if m.contains(" panicked: ")), "{echoed:?}");
|
||||
assert!(!caught_panic(&full.registry, &echoed), "{echoed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_structure() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
@@ -4766,10 +4836,10 @@ fn commands_after_the_largest_ids_take_free_ones() {
|
||||
s.doc.parts.insert(id, wordcraft_doc::Part { kind: wordcraft_doc::PartKind::Comment, blocks, ..Default::default() });
|
||||
s.doc.comments.insert(id, wordcraft_doc::Comment { part: id, ..Default::default() });
|
||||
}
|
||||
s.doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
s.doc.numbering.abstracts[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].abstract_id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].abstract_id = u32::MAX;
|
||||
let before = s.doc.clone();
|
||||
assert_eq!(run(&mut s, "review.newComment", json!({"text": "new"})), json!({"id": 1, "story": 1}));
|
||||
assert_eq!(run(&mut s, "review.reply", json!({"id": 0, "text": "re"})), json!({"id": 2}));
|
||||
@@ -4866,7 +4936,7 @@ fn word_count_includes_text_boxes_by_default() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_in_words_but_not_characters() {
|
||||
let mut d = wordcraft_doc::Document::new();
|
||||
let num_id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Apple", wordcraft_doc::CharProps::default());
|
||||
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: num_id, level: 0 });
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))];
|
||||
@@ -5839,6 +5909,111 @@ fn modify_style_takes_hex_colours_like_format_color() {
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))), "the hex string also reads inside {{\"Rgb\": …}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_steps_share_the_style_sheet_and_lists_until_a_command_changes_them() {
|
||||
// Every undo step copied the whole style sheet and list definitions: kilobytes a style, so
|
||||
// typing into a document with hundreds of styles cost megabytes a step.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("One two three."));
|
||||
for _ in 0..5 {
|
||||
run(&mut s, "text.insert", json!({"text": "word "}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
}
|
||||
assert_eq!(s.undo_labels().len(), 5, "five undo steps");
|
||||
assert!(Arc::strong_count(&s.doc.styles) >= 6, "the document and each step hold one style sheet");
|
||||
assert!(Arc::strong_count(&s.doc.numbering) >= 6, "and one set of list definitions");
|
||||
// A command that changes a style copies the sheet once; undo puts the shared one back.
|
||||
let sheet = Arc::clone(&s.doc.styles);
|
||||
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"bold": true}}));
|
||||
assert!(!Arc::ptr_eq(&sheet, &s.doc.styles));
|
||||
assert_ne!(*sheet, *s.doc.styles, "the change is in the new copy only");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(Arc::ptr_eq(&sheet, &s.doc.styles), "undo restores the step's own sheet");
|
||||
// Lists likewise.
|
||||
let lists = Arc::clone(&s.doc.numbering);
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
assert!(!Arc::ptr_eq(&lists, &s.doc.numbering));
|
||||
assert!(s.doc.numbering.nums.len() > lists.nums.len());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(Arc::ptr_eq(&lists, &s.doc.numbering));
|
||||
}
|
||||
|
||||
/// The same at scale: 1,000 edits of a document with 600 styles and 60 lists of nine levels. The
|
||||
/// history keeps the newest `MAX_UNDO` steps and drops the oldest. Its steps hold one style sheet
|
||||
/// for each style change they span, however many steps there are, and one set of lists. A sheet
|
||||
/// that only dropped steps held is freed.
|
||||
#[test]
|
||||
fn a_thousand_edits_of_a_large_document_keep_the_newest_steps_and_share_its_styles() {
|
||||
use crate::doc_state::MAX_UNDO;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Weak;
|
||||
|
||||
let body: Vec<String> = (0..200).map(|i| format!("Paragraph {i} of the body, with a few words in it.")).collect();
|
||||
let mut doc = wordcraft_doc::Document::from_text(&body.join("\n"));
|
||||
let sheet = Arc::make_mut(&mut doc.styles);
|
||||
let normal = sheet.get("Normal").cloned().unwrap();
|
||||
for i in 0..600 {
|
||||
let (id, name) = (format!("Custom{i}"), format!("Custom {i}"));
|
||||
sheet.upsert(wordcraft_doc::Style {
|
||||
id,
|
||||
name,
|
||||
based_on: Some("Normal".into()),
|
||||
builtin: false,
|
||||
default: false,
|
||||
quick: false,
|
||||
..normal.clone()
|
||||
});
|
||||
}
|
||||
for _ in 0..60 {
|
||||
Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Legal);
|
||||
}
|
||||
let mut s = Session::new(doc);
|
||||
|
||||
// An edit types a word, except every hundredth, which changes a style: one undo step each.
|
||||
// After each edit: the text, and the sheet and lists the document then had.
|
||||
let edits = 1000;
|
||||
let mut after = Vec::with_capacity(edits);
|
||||
let mut probe = Weak::new();
|
||||
for i in 0..edits {
|
||||
if i % 100 == 50 {
|
||||
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"size": 10 + i / 100}}));
|
||||
} else {
|
||||
run(&mut s, "text.insert", json!({"text": format!("w{i} ")}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
}
|
||||
after.push((text(&s), Arc::downgrade(&s.doc.styles), Arc::downgrade(&s.doc.numbering)));
|
||||
// Edit 50's sheet is replaced at edit 150, so the step taken before edit 150 is the last
|
||||
// to hold it. That step is the oldest one kept until edit 650 pushes it out.
|
||||
match i {
|
||||
50 => probe = Arc::downgrade(&s.doc.styles),
|
||||
649 => assert!(probe.upgrade().is_some(), "a kept step still holds edit 50's sheet"),
|
||||
650 => assert!(probe.upgrade().is_none(), "dropping the last step that held edit 50's sheet frees it"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(s.undo_steps_so_far(), edits as u64, "one undo step an edit");
|
||||
assert_eq!(s.undo_depth(), MAX_UNDO);
|
||||
|
||||
// Undo goes back through the newest steps, newest first. Each restores the text and the very
|
||||
// sheet and lists the document had after that edit: the steps share them, not copies.
|
||||
let (mut sheets, mut lists) = (HashSet::new(), HashSet::new());
|
||||
for k in 0..=MAX_UNDO {
|
||||
let (txt, sheet, numbering) = &after[edits - 1 - k];
|
||||
assert_eq!(&text(&s), txt, "after {k} undos");
|
||||
assert!(Arc::ptr_eq(&s.doc.styles, &sheet.upgrade().unwrap()), "the sheet after {k} undos");
|
||||
assert!(Arc::ptr_eq(&s.doc.numbering, &numbering.upgrade().unwrap()), "the lists after {k} undos");
|
||||
sheets.insert(Arc::as_ptr(&s.doc.styles));
|
||||
lists.insert(Arc::as_ptr(&s.doc.numbering));
|
||||
if k < MAX_UNDO {
|
||||
assert!(s.undo(), "undo {}", k + 1);
|
||||
}
|
||||
}
|
||||
assert!(!s.undo(), "the oldest 500 steps were dropped: edits 0 to 499 can't be undone");
|
||||
// The 501 states, the document's and the kept steps', hold six sheets (edit 450's, and those
|
||||
// of the changes at 550 to 950) and one set of lists.
|
||||
assert_eq!(sheets.len(), 6);
|
||||
assert_eq!(lists.len(), 1);
|
||||
}
|
||||
|
||||
/// #865: the other colours in CharProps and ParaProps (`underlineColor`, `shading`, a border's
|
||||
/// `color`) take `RRGGBB` / `#RRGGBB` like `para.borders` and `table.shading`, and still `[r, g, b]`.
|
||||
#[test]
|
||||
@@ -6560,7 +6735,7 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
|
||||
/// document alone, is not saved, and starts at None on each entry to Read Mode.
|
||||
/// document alone, starts at None and is kept between documents and runs as a preference.
|
||||
#[test]
|
||||
fn reading_page_color_is_a_transient_view_setting() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
@@ -6571,18 +6746,46 @@ fn reading_page_color_is_a_transient_view_setting() {
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
|
||||
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
|
||||
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
|
||||
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
|
||||
assert_eq!(s.doc, original);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
// The color stays for the next entry to Read Mode, while the other reading controls reset.
|
||||
run(&mut s, "view.readTextSize", json!({"value": 150}));
|
||||
run(&mut s, "view.printLayout", json!({}));
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
assert_eq!(s.view.read_zoom, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_page_color_follows_saved_prefs_and_new_documents() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
let mut s = s();
|
||||
s.prefs.read_page_color = ReadPageColor::Sepia;
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
let first = s.document_id();
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
s.activate(first).unwrap();
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
run(&mut s, "file.open", json!({"path": "reading-color.docx", "data": ""}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
s.set_document(wordcraft_doc::Document::new());
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
|
||||
@@ -6764,6 +6967,23 @@ fn inserted_chart_is_saved_and_reopened_with_its_data() {
|
||||
assert!(first_chart(&s3.doc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redrawing_a_chart_keeps_the_chart_as_read() {
|
||||
// A chart read from a file keeps its original (`source`) through drawing it again (when it
|
||||
// opens or is resized): saving writes that back while the chart is unchanged.
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicKind};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
let spec = wordcraft_doc::chart::ChartSpec::sample(wordcraft_doc::chart::ChartType::Column);
|
||||
let source = Arc::new(Embedded { xml: format!("{} mime=\"x\" ole=\"false\"/>", Embedded::ODF_OBJECT), refs: Vec::new() });
|
||||
let graphic = Graphic { kind: GraphicKind::Chart, chart: Some(Arc::new(spec)), source: Some(source.clone()), ..Default::default() };
|
||||
let mut o = InlineObject::Graphic { w: 300.0, h: 200.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
|
||||
crate::cmd::chart::redraw(&mut o, &[]);
|
||||
let InlineObject::Graphic { graphic, .. } = &o else { panic!() };
|
||||
assert!(!graphic.items.is_empty(), "drawn");
|
||||
assert_eq!(graphic.source.as_deref(), Some(&*source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_edits_are_one_undo_step_each() {
|
||||
use wordcraft_doc::chart::{ChartType, LegendPos};
|
||||
@@ -6967,6 +7187,26 @@ fn password_protected_documents_round_trip() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_shape_drops_the_geometry_kept_from_a_file() {
|
||||
// An ODT custom shape keeps its geometry as read, to save back; made another shape, it isn't
|
||||
// that shape any more.
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
|
||||
let geometry = |s: &Session| match cmd::objects::selected(s) {
|
||||
Some((_, wordcraft_doc::InlineObject::Shape { extra, .. })) => extra.odf_geometry.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let Some((pos, _)) = cmd::objects::selected(&s) else { panic!() };
|
||||
let para = s.doc.para_mut(pos.story, &pos.path).unwrap();
|
||||
if let Some(wordcraft_doc::InlineObject::Shape { extra, .. }) = para.object_at_mut(pos.off) {
|
||||
extra.odf_geometry = Some(std::sync::Arc::new(r#"<draw:enhanced-geometry draw:type="cloud"></draw:enhanced-geometry>"#.to_string()));
|
||||
}
|
||||
assert!(geometry(&s).is_some());
|
||||
run(&mut s, "shape.change", json!({"kind": "ellipse"}));
|
||||
assert_eq!(geometry(&s), None);
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
|
||||
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
|
||||
#[test]
|
||||
@@ -7088,7 +7328,7 @@ fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
|
||||
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
|
||||
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
|
||||
@@ -7233,7 +7473,7 @@ fn delete_table_style_falls_back_and_undoes() {
|
||||
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
for name in ["Table Grid", "Normal Table", "TableGrid"] {
|
||||
@@ -7845,7 +8085,7 @@ fn list_buttons_follow_the_paragraphs_own_level() {
|
||||
// A bulleted list whose second level is numbered: at level 2 the paragraph is numbered.
|
||||
let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num;
|
||||
let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() };
|
||||
s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
|
||||
Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
|
||||
run(&mut s, "para.listLevel", json!({"level": 1}));
|
||||
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false });
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
@@ -8638,7 +8878,7 @@ const PORTUGUESE_TEST_AFF: &str =
|
||||
"SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n";
|
||||
const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n";
|
||||
|
||||
/// #1063: File › Options › Proofing › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// #1063: File › Options › Language › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// searched first and reported per language; a missing folder or a relative path is refused and
|
||||
/// changes nothing. The folder holds the same test dictionaries the other tests install, so tests
|
||||
/// running meanwhile see the same words; it stays set for the rest of the run for that reason.
|
||||
@@ -9232,7 +9472,7 @@ fn default_proofing_language_survives_a_restart() {
|
||||
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
|
||||
|
||||
let mut english = wordcraft_doc::Document::from_text("English document");
|
||||
english.styles.default_chr.lang = Some("en-GB".into());
|
||||
Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into());
|
||||
let bytes = crate::io::save_bytes("english.docx", &english).unwrap();
|
||||
let data = crate::cmd::insert::base64_encode(&bytes);
|
||||
run(&mut second, "file.open", json!({"path": "english.docx", "data": data}));
|
||||
@@ -9875,7 +10115,7 @@ fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
|
||||
|
||||
let mut s = s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = None;
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "3,50"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
@@ -10538,8 +10778,14 @@ fn inspect_document_hidden_text_in_every_story_and_from_styles() {
|
||||
run(&mut s, "insert.footer", json!({"text": "f"}));
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
let hidden = CharProps { hidden: Some(true), ..Default::default() };
|
||||
s.doc.styles.upsert(Style { id: "HideC".into(), name: "Hide C".into(), kind: StyleKind::Character, chr: hidden.clone(), ..Default::default() });
|
||||
s.doc.styles.upsert(Style {
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
id: "HideC".into(),
|
||||
name: "Hide C".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: hidden.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
id: "HideP".into(),
|
||||
name: "Hide P".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -10650,7 +10896,7 @@ fn inspect_document_removes_text_hidden_by_document_defaults() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Paragraph, para_block};
|
||||
let mut s = s();
|
||||
s.doc.styles.default_chr.hidden = Some(true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.hidden = Some(true);
|
||||
s.doc.body = vec![
|
||||
para_block(Paragraph::with_text("hide-default", CharProps::default())),
|
||||
para_block(Paragraph::with_text("keep-default", CharProps { hidden: Some(false), ..Default::default() })),
|
||||
@@ -10693,7 +10939,7 @@ fn inspect_document_keeps_content_control_markers() {
|
||||
fn inspect_document_lists_tables_whose_style_hides_text() {
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
|
||||
let mut s = s();
|
||||
s.doc.styles.upsert(Style {
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
id: "HideHeader".into(),
|
||||
name: "Hide Header".into(),
|
||||
kind: StyleKind::Table,
|
||||
@@ -11287,8 +11533,8 @@ fn toc_keeps_direct_number_tabs() {
|
||||
d.last_section.page_w = 220.0;
|
||||
d.last_section.margin_left = 20.0;
|
||||
d.last_section.margin_right = 20.0;
|
||||
d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
{
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
p.props.style = Some("TOCHeading".into());
|
||||
@@ -11590,11 +11836,11 @@ fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
|
||||
body.chr.size = Some(20.0);
|
||||
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
|
||||
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
|
||||
s.doc.styles.styles.push(body.clone());
|
||||
s.doc.styles.styles.push(grid.clone());
|
||||
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
|
||||
s.doc.styles.default_chr.lang = Some("de-DE".into());
|
||||
s.doc.styles.default_chr.size = Some(11.0);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(body.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).latent_styles = Some("<w:latentStyles/>".into());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0);
|
||||
run(&mut s, "design.styleSet", json!({ "name": name }));
|
||||
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
|
||||
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
|
||||
@@ -11664,8 +11910,8 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
|
||||
let mut s = s();
|
||||
s.doc.styles.default_chr.size = None;
|
||||
s.doc.styles.default_chr.size_cs = Some(10.0);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size = None;
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0);
|
||||
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
|
||||
ch.chr.size = Some(20.0);
|
||||
ch.chr.size_cs = Some(22.0);
|
||||
@@ -11674,7 +11920,7 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
parts.header_chr.size = Some(14.0);
|
||||
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
|
||||
tb.table = Some(parts);
|
||||
s.doc.styles.styles.extend([ch, tb]);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]);
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
|
||||
let st = &s.doc.styles;
|
||||
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
|
||||
@@ -11746,6 +11992,93 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
|
||||
/// draws one per character; Format state reports it, and none takes it off.
|
||||
#[test]
|
||||
fn emphasis_marks_are_set_and_drawn() {
|
||||
use wordcraft_doc::props::EmphasisMark;
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("強調する"));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.emphasisMark", json!({"mark": "circle"}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props_of_char(0).emphasis, Some(EmphasisMark::Circle));
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["emphasisMark"], "circle");
|
||||
// One mark per character: hollow circles, drawn as shapes.
|
||||
let draws = s.layout().pages.first().map(|pg| wordcraft_layout::display::page_display(&s.doc, pg, &Default::default())).unwrap_or_default();
|
||||
let marks = draws
|
||||
.iter()
|
||||
.filter(|d| matches!(d, wordcraft_layout::display::Draw::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, fill: None, .. }))
|
||||
.count();
|
||||
assert_eq!(marks, 4);
|
||||
run(&mut s, "format.emphasisMark", json!({"mark": "none"}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).emphasis, Some(EmphasisMark::None));
|
||||
assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err());
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › OpenDocument files: a preset, or one case's value (which makes the
|
||||
/// profile custom); junk is refused; files opened afterwards follow it.
|
||||
#[test]
|
||||
fn odf_interpretation_is_an_option_files_follow() {
|
||||
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
|
||||
let mut s = s();
|
||||
assert_eq!(s.prefs.odf, OdfProfile::word(), "Microsoft Word's style is the default");
|
||||
let r = run(&mut s, "file.options", json!({"odf": {"preset": "LibreOffice"}}));
|
||||
assert_eq!(r["odf"]["preset"], json!("libreoffice"));
|
||||
assert_eq!(s.prefs.odf.effective(), OdfProfile::libreoffice());
|
||||
// The two display styles, back and forth; Word's by its earlier id too.
|
||||
let r = run(&mut s, "file.options", json!({"odf": {"preset": "word"}}));
|
||||
assert_eq!(r["odf"]["preset"], json!("word"));
|
||||
assert_eq!(s.prefs.odf.effective(), OdfProfile::word());
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
|
||||
let r = run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}}));
|
||||
assert_eq!(r["odf"]["preset"], json!("word"));
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
|
||||
run(&mut s, "file.options", json!({"odf": {"columnGap": 18.0}}));
|
||||
assert_eq!((s.prefs.odf.preset, s.prefs.odf.effective().column_gap), (OdfPreset::Custom, 18.0));
|
||||
for junk in [
|
||||
json!({"odf": {"nope": 1}}),
|
||||
json!({"odf": {"columnGap": -1}}),
|
||||
json!({"odf": {"hairline": 1e9}}),
|
||||
json!({"odf": 3}),
|
||||
json!({"odf": {"preset": "google"}}),
|
||||
] {
|
||||
assert!(s.run("file.options", &junk).is_err(), "{junk}");
|
||||
}
|
||||
assert_eq!(s.prefs.odf.effective().column_gap, 18.0, "a refused change leaves it");
|
||||
|
||||
// A tab stop in an indented paragraph of a file that doesn't say how tabs are measured.
|
||||
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:margin-left="0.5in"><style:tab-stops><style:tab-stop style:position="1in"/></style:tab-stops></style:paragraph-properties></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">a</text:p></office:text></office:body></office:document-content>"#;
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
let tab = |s: &Session| {
|
||||
let (d, _) = crate::io::open_bytes_in("t.odt", &bytes, None, &s.region(), &s.prefs.odf).unwrap();
|
||||
d.body[0].as_para().unwrap().props.tabs.clone().unwrap_or_default().first().map(|t| t.pos)
|
||||
};
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}}));
|
||||
assert_eq!(tab(&s), Some(72.0), "WordCraft: from the margin");
|
||||
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
|
||||
assert_eq!(tab(&s), Some(108.0), "LibreOffice: from the indent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
|
||||
// Opening an ODT draws its charts (from their data); that changes nothing in the document,
|
||||
// so the paragraph showing one is still written as read.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "insert.chart", json!({"type": "column"}));
|
||||
let first = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &first).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
|
||||
let mut content = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap();
|
||||
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
|
||||
}
|
||||
|
||||
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
|
||||
@@ -11836,7 +12169,7 @@ fn alt_chunks_open_in_place_as_word_brings_them_in() {
|
||||
fn text_from_file_brings_the_files_pictures() {
|
||||
let dir = scratch_dir("text-from-file-pictures");
|
||||
let mut src = Session::new(wordcraft_doc::Document::new());
|
||||
let list = src.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet);
|
||||
// A list item, then a paragraph (a single paragraph would take the caret's formatting).
|
||||
src.doc.split_paragraph(&Pos::body(0, 0)).unwrap();
|
||||
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering =
|
||||
@@ -11851,7 +12184,7 @@ fn text_from_file_brings_the_files_pictures() {
|
||||
let mut s = s();
|
||||
// A picture and a list of our own first, so the file's key and list id would point at ours.
|
||||
let ours = s.doc.add_media(vec![9, 9, 9], "png");
|
||||
let our_list = s.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let shown: Vec<String> = s
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open.
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -118,9 +119,9 @@ fn font_commands_override_inherited_complex_script_properties_explicitly() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
|
||||
let mut s = session("שלום ABC", true);
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.default_chr.bold_cs = Some(true);
|
||||
s.doc.styles.default_chr.italic_cs = Some(true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({"value": false}));
|
||||
run(&mut s, "format.italic", json!({"value": false}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user