mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 00:44:27 -06:00
Compare commits
375
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c8599edc | ||
|
|
7296cf64de | ||
|
|
a8db235e05 | ||
|
|
4706420e94 | ||
|
|
ae7ae89eb2 | ||
|
|
1327d20055 | ||
|
|
9dba000e86 | ||
|
|
7013411a2f | ||
|
|
411a4915a1 | ||
|
|
639b8133ea | ||
|
|
e0659e45ce | ||
|
|
ad5e1a65fc | ||
|
|
bf426c1804 | ||
|
|
b67d3b8735 | ||
|
|
e3fef2a9c3 | ||
|
|
060768f1ed | ||
|
|
45db128c15 | ||
|
|
118eb8af1f | ||
|
|
30c1279fd8 | ||
|
|
f936270317 | ||
|
|
0fab74aa38 | ||
|
|
aef086976b | ||
|
|
92e0e67f3a | ||
|
|
a34c94e4c1 | ||
|
|
a42c9eaab3 | ||
|
|
12869b8e02 | ||
|
|
8548b33be1 | ||
|
|
abd5a6e957 | ||
|
|
8dac5c7802 | ||
|
|
2922fe566b | ||
|
|
43f94a5dca | ||
|
|
d40d1b2718 | ||
|
|
05f781cfda | ||
|
|
8a97427f51 | ||
|
|
92eac2aa7c | ||
|
|
29a5abf3e7 | ||
|
|
bd9e726cda | ||
|
|
15b608660f | ||
|
|
52fbc7f068 | ||
|
|
d75364067d | ||
|
|
d102b2f08b | ||
|
|
5574b32600 | ||
|
|
8dc3f0cd5f | ||
|
|
f1b1077327 | ||
|
|
7b8c0ba5eb | ||
|
|
0b61f8b426 | ||
|
|
0a5d5d29f8 | ||
|
|
bce47689ad | ||
|
|
5ad865831f | ||
|
|
642f5dd6bf | ||
|
|
0b7cfa86fe | ||
|
|
85eb8bb92e | ||
|
|
4b0d399599 | ||
|
|
fda8758ec9 | ||
|
|
c5fe6c6816 | ||
|
|
438f82977a | ||
|
|
72a7afd9b5 | ||
|
|
36ed94a09e | ||
|
|
0655e651fb | ||
|
|
0e3f4dd315 | ||
|
|
e01a88b0be | ||
|
|
ae257ba784 | ||
|
|
0495fbb789 | ||
|
|
e7e9bc2759 | ||
|
|
be75ee90e3 | ||
|
|
480511eaa5 | ||
|
|
aa262cf7d8 | ||
|
|
2d97c8443e | ||
|
|
d5d2b22dac | ||
|
|
2794ae44e9 | ||
|
|
5125de6f1f | ||
|
|
59030b1bb5 | ||
|
|
4050f1bf8b | ||
|
|
02e74a5d9c | ||
|
|
d5a1c0e181 | ||
|
|
69d2a2e131 | ||
|
|
661a790540 | ||
|
|
04217419b6 | ||
|
|
4afff61f10 | ||
|
|
e76293a564 | ||
|
|
e0d8312fbe | ||
|
|
523e319424 | ||
|
|
735c2a5382 | ||
|
|
7592d1440e | ||
|
|
fde39ab061 | ||
|
|
d34e341d75 | ||
|
|
535b675e8a | ||
|
|
c8618d3e05 | ||
|
|
a891d861b7 | ||
|
|
85434d2c20 | ||
|
|
833c0154aa | ||
|
|
0a3fb5828e | ||
|
|
7634553900 | ||
|
|
cd4f755dcf | ||
|
|
c4d5f70c1d | ||
|
|
de4ca29309 | ||
|
|
b8a7e2acdb | ||
|
|
f0fb31c0c9 | ||
|
|
4cd6ffa564 | ||
|
|
52934ef110 | ||
|
|
3291be0fbf | ||
|
|
5dfb3acf15 | ||
|
|
4259dd1b2e | ||
|
|
e344bb4243 | ||
|
|
4a507de3d6 | ||
|
|
d07f50dd8b | ||
|
|
87e08051bf | ||
|
|
5c0a62a736 | ||
|
|
855a0fe9bd | ||
|
|
5f1db14954 | ||
|
|
b016ecc26c | ||
|
|
625f598b64 | ||
|
|
fa791d29b2 | ||
|
|
796b39dde6 | ||
|
|
33fa77a892 | ||
|
|
efd476cbc7 | ||
|
|
d3fcf10bcc | ||
|
|
b9936609dc | ||
|
|
7152143e45 | ||
|
|
c40719f431 | ||
|
|
6d0bd73127 | ||
|
|
cd81d5da49 | ||
|
|
b7c2fe8dff | ||
|
|
586f85df96 | ||
|
|
6367400215 | ||
|
|
9655b2da50 | ||
|
|
66e169c343 | ||
|
|
1c6c7c732e | ||
|
|
bd8bc96cb5 | ||
|
|
7830333c34 | ||
|
|
e349439bb6 | ||
|
|
4c8c1f1268 | ||
|
|
caeb87c754 | ||
|
|
0f5a701171 | ||
|
|
677e610886 | ||
|
|
d2a2709e14 | ||
|
|
050b6b0da3 | ||
|
|
6b163b33dc | ||
|
|
4ba444a342 | ||
|
|
4821a13ee0 | ||
|
|
dba2b3b5e9 | ||
|
|
beab4e8e9c | ||
|
|
852e67d587 | ||
|
|
9b6e9564a2 | ||
|
|
c9090ba95a | ||
|
|
c606533853 | ||
|
|
eb100edfd6 | ||
|
|
c38dc17ba9 | ||
|
|
5997aef2f9 | ||
|
|
73356ed82a | ||
|
|
6d3b51fab1 | ||
|
|
750e26657e | ||
|
|
f7e060ac3f | ||
|
|
b61aee61ba | ||
|
|
bc0f691c13 | ||
|
|
cac121fd89 | ||
|
|
ea8c45a4ca | ||
|
|
4774749a05 | ||
|
|
20a10a5f33 | ||
|
|
e53cae3482 | ||
|
|
7855a2d814 | ||
|
|
871401509c | ||
|
|
4ce92e8afd | ||
|
|
746577406c | ||
|
|
0131d5c3aa | ||
|
|
fa59ed0e8a | ||
|
|
b29f3a8a2e | ||
|
|
e7668ae53e | ||
|
|
7aea5f8cab | ||
|
|
3b6547de8f | ||
|
|
e0dd821d4c | ||
|
|
e4310ec5c7 | ||
|
|
7c3a5ccd34 | ||
|
|
ecbabc327c | ||
|
|
34b4caf317 | ||
|
|
8ab285e61d | ||
|
|
490437fa87 | ||
|
|
fa153b932c | ||
|
|
4925e06391 | ||
|
|
5c2e5ce0d0 | ||
|
|
274d40fc4d | ||
|
|
570c39e287 | ||
|
|
595cddaaef | ||
|
|
f1b5f0b4de | ||
|
|
ddb1d37de3 | ||
|
|
d98a335ae8 | ||
|
|
bbc6d94dbe | ||
|
|
d0498254a2 | ||
|
|
9fd9ce54b7 | ||
|
|
d94bbc8fe0 | ||
|
|
da914ce674 | ||
|
|
18a26b41ce | ||
|
|
9f9190f0d7 | ||
|
|
1c7c4e66e0 | ||
|
|
2d6da7a326 | ||
|
|
30a439899b | ||
|
|
875a62043a | ||
|
|
7ca1dd1802 | ||
|
|
729cd0a5fc | ||
|
|
a5b1518a10 | ||
|
|
4e334166e5 | ||
|
|
8df01e481f | ||
|
|
4ac7292d62 | ||
|
|
67c9b8ac31 | ||
|
|
7834197fbc | ||
|
|
401e7c9169 | ||
|
|
3daa6b1e78 | ||
|
|
e4ba85346d | ||
|
|
ebdca4feb8 | ||
|
|
44eb54eef9 | ||
|
|
75a905802c | ||
|
|
2c17b04f9a | ||
|
|
951b255b80 | ||
|
|
37a627c61e | ||
|
|
0ff06b9d86 | ||
|
|
d5495ea4f8 | ||
|
|
938e13e9c7 | ||
|
|
1787766d6f | ||
|
|
afc01f8328 | ||
|
|
9e391e97f4 | ||
|
|
2113f52db4 | ||
|
|
098cf86dc8 | ||
|
|
e99e28aebd | ||
|
|
4abae255e0 | ||
|
|
0b6e052d73 | ||
|
|
45cfc55116 | ||
|
|
a3b50342f9 | ||
|
|
ce4c401c4a | ||
|
|
b8ae10e453 | ||
|
|
f4f9d28d99 | ||
|
|
e17ad54b15 | ||
|
|
9e2f6ad330 | ||
|
|
6f125461dd | ||
|
|
31a37bcf00 | ||
|
|
2025c48e9b | ||
|
|
83b319e7c3 | ||
|
|
ca1f584ae0 | ||
|
|
7378ac5063 | ||
|
|
7e967d4a3a | ||
|
|
f5038a12e3 | ||
|
|
1bbced51a4 | ||
|
|
9de2227af2 | ||
|
|
7d0ba2be63 | ||
|
|
f69b003fe1 | ||
|
|
c9046233a0 | ||
|
|
0e7dc8da53 | ||
|
|
837d2b0748 | ||
|
|
220bcf947a | ||
|
|
7d27d8299a | ||
|
|
71dba44a0a | ||
|
|
3a22b72132 | ||
|
|
f342b6ff1f | ||
|
|
95669b9eb9 | ||
|
|
88ac0d7174 | ||
|
|
3b20ea46de | ||
|
|
4524bc6ba9 | ||
|
|
49a3cd223b | ||
|
|
061d40d5eb | ||
|
|
05a37f6fe2 | ||
|
|
d95cb46da5 | ||
|
|
8c44eb1b98 | ||
|
|
933bccf052 | ||
|
|
9aa3d93ecf | ||
|
|
bffb6e6705 | ||
|
|
a449c59232 | ||
|
|
a82d509793 | ||
|
|
4bfd84514c | ||
|
|
e34840a78a | ||
|
|
066af3b620 | ||
|
|
d0f273bbfd | ||
|
|
c40d5159b7 | ||
|
|
841dd538dc | ||
|
|
541483ce0b | ||
|
|
364495deb5 | ||
|
|
c85d454e09 | ||
|
|
1c1ee15cff | ||
|
|
1a950a3631 | ||
|
|
0c859e334d | ||
|
|
f4ff1f842d | ||
|
|
d8899ed345 | ||
|
|
c10df4e7bc | ||
|
|
2a82cc4a22 | ||
|
|
5ce611091e | ||
|
|
878e5e3457 | ||
|
|
3cfa9ca721 | ||
|
|
ff4377bf7e | ||
|
|
71aa12c076 | ||
|
|
cfa99b45ce | ||
|
|
e11943f818 | ||
|
|
33070c575c | ||
|
|
86a1b000ef | ||
|
|
b6d89388c8 | ||
|
|
914e1d5872 | ||
|
|
ba62c2255e | ||
|
|
0ea610d0ff | ||
|
|
a4e9b07701 | ||
|
|
2fb626e548 | ||
|
|
827fc6e07a | ||
|
|
e2b92cde84 | ||
|
|
678af4368d | ||
|
|
ed3c3ea038 | ||
|
|
4940d07dad | ||
|
|
dc2b990602 | ||
|
|
92683b6796 | ||
|
|
d858066cf7 | ||
|
|
006b6480ed | ||
|
|
402ee4893c | ||
|
|
21bba8e7de | ||
|
|
d5de454641 | ||
|
|
11fce726e3 | ||
|
|
ed97087907 | ||
|
|
2b2398ae55 | ||
|
|
d7c7889c82 | ||
|
|
0b1704677d | ||
|
|
ec1cb78a20 | ||
|
|
8b06dd8666 | ||
|
|
28b5cba00c | ||
|
|
14526d0d81 | ||
|
|
97eea6e12f | ||
|
|
8b679452eb | ||
|
|
fd27713d20 | ||
|
|
c488558a1c | ||
|
|
addf03751d | ||
|
|
8578d99ccd | ||
|
|
77bfbd6f11 | ||
|
|
38253611d2 | ||
|
|
876ce9ab48 | ||
|
|
0a23c4febe | ||
|
|
65f36f9b8a | ||
|
|
f50a5cc5e9 | ||
|
|
12582bb529 | ||
|
|
badd0c5ef9 | ||
|
|
2f46f194a1 | ||
|
|
6bc7810c8c | ||
|
|
f9c6ddba3a | ||
|
|
00d4e7a291 | ||
|
|
bfbb3d05a4 | ||
|
|
9b6d326730 | ||
|
|
212df61bc7 | ||
|
|
0abe06e236 | ||
|
|
843bf26ae8 | ||
|
|
44055678c5 | ||
|
|
9ea1d93a8d | ||
|
|
ab3ad718a0 | ||
|
|
ad7eea67c1 | ||
|
|
be492ddc98 | ||
|
|
e8c852d490 | ||
|
|
7a3c3b40af | ||
|
|
5288fed0ea | ||
|
|
1e306ed5ab | ||
|
|
9e25e978e5 | ||
|
|
3248047b76 | ||
|
|
5edcd6765b | ||
|
|
80bf6ed96b | ||
|
|
665af07277 | ||
|
|
2e7852fa4d | ||
|
|
3ee5840d6d | ||
|
|
ee649d03da | ||
|
|
19094d4f3a | ||
|
|
fb3d600428 | ||
|
|
7b1c0402b0 | ||
|
|
365b8c003e | ||
|
|
1bec25c02d | ||
|
|
c3703ebc73 | ||
|
|
ede933dcc2 | ||
|
|
d955ff2874 | ||
|
|
bd320e65a5 | ||
|
|
3445b7226d | ||
|
|
f2d7a4fe7d | ||
|
|
25e4986998 | ||
|
|
a912aacf73 | ||
|
|
256d5d084d | ||
|
|
f0c1b64bc8 | ||
|
|
0164ac86fa | ||
|
|
12a1316f10 |
@@ -27,6 +27,10 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -42,6 +42,10 @@ jobs:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -64,6 +68,11 @@ jobs:
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
# apt falls back to mirrors outside ubuntu.com when it doesn't resolve (as on 11 Nov 2026).
|
||||
- name: Configure apt mirrors
|
||||
if: runner.os == 'Linux'
|
||||
uses: storytold/craft-common-actions/actions/configure-apt-mirrors@main
|
||||
|
||||
- name: Install Linux build and software-rendering dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
|
||||
@@ -18,7 +18,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -31,6 +38,7 @@ jobs:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
Generated
+42
-24
@@ -1081,7 +1081,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1135,7 +1135,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1145,6 +1145,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1158,10 +1159,21 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -2970,7 +2982,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6795,7 +6807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6827,30 +6839,35 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11-dl",
|
||||
"x11rb",
|
||||
"xkbcommon-dl",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-mcp",
|
||||
"wordcraft-render",
|
||||
"wordcraft-zotero",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6864,7 +6881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6881,7 +6898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6906,7 +6923,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6921,6 +6938,7 @@ dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
"regex-automata",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6939,7 +6957,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6953,7 +6971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6980,7 +6998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6988,7 +7006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -7008,7 +7026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7020,7 +7038,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7028,7 +7046,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7049,7 +7067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7063,7 +7081,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7096,8 +7114,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7116,7 +7135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7279,7 +7298,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7550,7 +7569,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.18.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -67,9 +67,9 @@ wuff = "0.2.9"
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
@@ -134,7 +134,7 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
+18
@@ -48,3 +48,21 @@ _Avoid_: User dictionary (in the interface)
|
||||
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
|
||||
comes after it).
|
||||
_Avoid_: Custom dictionaries
|
||||
|
||||
## Export
|
||||
|
||||
**Strict export**:
|
||||
An export that writes nothing, and reports why, when a character or picture the output is meant
|
||||
to show would be missing from it. Text the output doesn't show (hidden text, deletions without
|
||||
markup) isn't checked.
|
||||
_Avoid_: Safe mode, validated export
|
||||
|
||||
**Missing glyph**:
|
||||
A character the output draws that no face in the font fallback chain can draw, so it would appear
|
||||
as an empty box.
|
||||
_Avoid_: Tofu, box, notdef character
|
||||
|
||||
**Missing picture**:
|
||||
A picture the document refers to that the output cannot include: its part is absent, its data
|
||||
cannot be decoded, its format is unsupported, or it is linked from outside the file.
|
||||
_Avoid_: Broken image, dropped image
|
||||
|
||||
@@ -157,6 +157,7 @@ Command line:
|
||||
|
||||
```sh
|
||||
wordcraft-cli convert report.docx report.pdf # docx, pdf, odt, rtf, html, md, tex, txt, png
|
||||
wordcraft-cli convert report.docx report.pdf --strict # exit 3, no file, if a character or picture would be left out
|
||||
wordcraft-cli text report.docx # plain text
|
||||
wordcraft-cli inspect report.docx # structure as JSON
|
||||
wordcraft-cli run --template sample \
|
||||
@@ -164,6 +165,40 @@ wordcraft-cli run --template sample \
|
||||
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
|
||||
```
|
||||
|
||||
`convert --strict` (a `.pdf` or `.png` output only) and `render --strict` (a PNG of one page) refuse a silently incomplete render: they exit 3
|
||||
and write nothing if the output would draw a character no font has (an empty box) or leave out a
|
||||
picture. Exit codes are 0 (written), 1 (unreadable input, usage error, or `--strict` with another
|
||||
output type, checked before the input is read) and 3 (strict refusal). On exit 3, stderr has one
|
||||
`wordcraft-cli: strict: …` line per kind, then one JSON line:
|
||||
|
||||
```sh
|
||||
wordcraft-cli convert in.docx out.pdf --strict
|
||||
# wordcraft-cli: strict: 1 character cannot be drawn: U+2D30 (×3)
|
||||
# wordcraft-cli: strict: 1 picture(s) left out: word/media/image2.bin (header2.xml): unsupported format
|
||||
# {"schema":1,"strict":"failed","missing_glyphs":[{"cp":"U+2D30","count":3}],"missing_pictures":[{"part":"word/media/image2.bin","source_part":"word/header2.xml","reason":"unsupported_format"}],"truncated":false,"total_missing_glyphs":1,"total_missing_pictures":1,"glyphs_truncated":false,"pictures_truncated":false}
|
||||
```
|
||||
|
||||
The JSON line (schema 1, also what `file.strictReport` returns) has `schema`, `strict`
|
||||
(`"passed"` or `"failed"`), `missing_glyphs` (entries `{"cp", "count"}`, sorted by code point),
|
||||
`missing_pictures` (entries `{"part", "source_part", "reason"}`: the picture's part or target, the
|
||||
story part it sits in such as `word/header2.xml`, and a reason code; `source_part` is also
|
||||
`background` (the page picture), `body`, `header`, `footer`, `footnote`, `endnote`, `comment` or
|
||||
`text box` where the part name is not known (a document not opened from a DOCX), or a chunk path such as
|
||||
`word/afchunk1.docx > word/header1.xml` for a picture inside an embedded DOCX chunk), `truncated`, the two totals
|
||||
`total_missing_glyphs` and `total_missing_pictures`, and `glyphs_truncated` and
|
||||
`pictures_truncated` for each list. The reason codes are `missing_part`, `external_target`,
|
||||
`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. When a list is cut, the stderr line gives the total
|
||||
(`300 characters cannot be drawn (first 256 listed)`).
|
||||
Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals
|
||||
count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text
|
||||
without `--markup`), and a character a fallback font draws is fine. Text inside a WMF or EMF picture counts too, shaped in the font the picture names with no fallback. A Private Use Area character
|
||||
fails only if no installed font maps it. The result depends on the fonts installed on the host
|
||||
(see #5624 for a fixed font set). An external picture target is never fetched: it is reported, not
|
||||
loaded. Pictures dropped when a DOCX is opened (a missing part, an external target, or a `link only` picture with no embedded copy) are reported for DOCX input only. They have no page, so a PNG of any page is also refused for a missing or external picture anywhere in the document, unused headers included. `render --strict` checks only the page it
|
||||
writes (`--page`; `--scale` doesn't matter), prints nothing on stdout when it refuses, and reports the
|
||||
same way. The engine takes `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and
|
||||
`file.exportPng`, and `file.strictReport {markup?, page?}` returns the report without writing.
|
||||
|
||||
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
|
||||
|
||||
## For agents: CLI and MCP
|
||||
@@ -319,6 +354,11 @@ XWayland:
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
```
|
||||
|
||||
XWayland needs the X11 libraries, which the packages only recommend and the tarball doesn't
|
||||
install: libX11, libX11-xcb, libXcursor, libXi and libxkbcommon-x11 (on Debian and Ubuntu,
|
||||
`libx11-6`, `libx11-xcb1`, `libxcursor1`, `libxi6` and `libxkbcommon-x11-0`). Without them the app
|
||||
opens on native Wayland, whatever the setting says, and logs which ones are missing.
|
||||
|
||||
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
|
||||
to the AUR itself. Install one of them:
|
||||
| Package | What it installs | Choose it when |
|
||||
|
||||
+155
-2
File diff suppressed because one or more lines are too long
@@ -21,3 +21,8 @@ wordcraft-mcp = { workspace = true }
|
||||
wordcraft-zotero = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-fonts = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
+216
-62
@@ -11,23 +11,30 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_engine::{CmdError, Session};
|
||||
|
||||
const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
|
||||
USAGE:
|
||||
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
|
||||
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
|
||||
xml); --update-fields refreshes unlocked fields before saving;
|
||||
xml); --update-fields refreshes unlocked fields before saving
|
||||
(if protection blocks updates, they are skipped with a warning);
|
||||
an .xml file is a Word XML Document (Flat OPC), or with
|
||||
--format wordml2003 a Word 2003 XML Document;
|
||||
--markup (Print Markup): comments beside each page and
|
||||
tracked changes in pdf/png, a comment list in html/md/txt
|
||||
tracked changes in pdf/png, a comment list in html/md/txt;
|
||||
--strict (pdf or png output only) writes nothing and exits 3
|
||||
if a character can't be drawn or a picture is missing or
|
||||
undecodable; the report is on stderr, with one JSON line
|
||||
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
|
||||
wordcraft-cli text <file> plain text
|
||||
wordcraft-cli inspect <file> document structure (JSON)
|
||||
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
|
||||
wordcraft-cli render <file> <out.png> [--page N] [--scale S] [--strict]
|
||||
--strict writes nothing and exits 3 if a character on the
|
||||
page can't be drawn or a picture is missing or undecodable
|
||||
(report on stderr, as for convert --strict)
|
||||
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
|
||||
[--save OUT] [--print] [--author NAME]
|
||||
run commands headlessly, then save (--author names
|
||||
@@ -77,6 +84,14 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
|
||||
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
|
||||
}
|
||||
|
||||
/// A failure whose message may carry text from a file or from the engine's reading of one (a
|
||||
/// picture's part name, a parse error quoting the input): control, bidi and invisible characters
|
||||
/// are escaped, so it can't forge or spoof a line (the CLI's own messages, which have line breaks
|
||||
/// on purpose, are not routed through here). Backslashes stay: messages quote Windows paths.
|
||||
fn engine_error(e: impl fmt::Display) -> Failure {
|
||||
Failure::Error(escape_text(&e.to_string(), false))
|
||||
}
|
||||
|
||||
/// The error every subcommand gives when a required path is not there.
|
||||
fn missing_argument() -> Failure {
|
||||
format!("missing argument\n\n{USAGE}").into()
|
||||
@@ -85,11 +100,11 @@ fn missing_argument() -> Failure {
|
||||
/// Open a document for a subcommand. A password-protected Word file needs its password
|
||||
/// ([`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> {
|
||||
fn open(path: &Path, password: Option<&str>) -> Result<Session, Failure> {
|
||||
// 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).map_err(engine_error)?;
|
||||
let mut s = session(doc);
|
||||
s.path = Some(path.into());
|
||||
if encrypted {
|
||||
@@ -98,8 +113,8 @@ fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
|
||||
/// `pos` counts). `run` opens the value of `--file`.
|
||||
/// The commands that open a file, and which positional argument it is, excluding option values.
|
||||
/// `run` opens the value of `--file`.
|
||||
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
|
||||
|
||||
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
|
||||
@@ -108,8 +123,14 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0),
|
||||
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
|
||||
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let specs = match cmd {
|
||||
"convert" => CONVERT_OPTIONS,
|
||||
"render" => RENDER_OPTIONS,
|
||||
"zotero" => ZOTERO_OPTIONS,
|
||||
_ => PASSWORD_OPTIONS,
|
||||
};
|
||||
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
|
||||
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
|
||||
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
|
||||
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
|
||||
None => None,
|
||||
};
|
||||
@@ -145,8 +166,13 @@ fn password_from(args: &[String]) -> Result<Option<String>, String> {
|
||||
}
|
||||
|
||||
/// The positional arguments, skipping every option's value so an option value is never read as a
|
||||
/// path. Mirrors [`validate_options`], which has already checked the options are known.
|
||||
/// path. Uses the same option specifications as [`validate_options`].
|
||||
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
|
||||
positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect()
|
||||
}
|
||||
|
||||
/// Keep the original indices too, so [`text_args`] can open a positional path by its native name.
|
||||
fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
@@ -156,7 +182,7 @@ fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String>
|
||||
i += 2;
|
||||
} else {
|
||||
if !arg.starts_with("--") {
|
||||
out.push(arg);
|
||||
out.push(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -187,7 +213,7 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
|
||||
/// The command also checks whether the open document is protected: this name determines which
|
||||
/// named editing exceptions the session can use, so the flag must not bypass that check.
|
||||
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
|
||||
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
|
||||
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| escape_text(&format!("--author: {e}"), false))
|
||||
}
|
||||
|
||||
fn arg_value(args: &[String], name: &str) -> Option<String> {
|
||||
@@ -299,10 +325,12 @@ const CONVERT_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--markup", takes_value: false },
|
||||
OptionSpec { name: "--format", takes_value: true },
|
||||
OptionSpec { name: "--update-fields", takes_value: false },
|
||||
OptionSpec { name: "--strict", takes_value: false },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--strict", takes_value: false },
|
||||
OptionSpec { name: "--page", takes_value: true },
|
||||
OptionSpec { name: "--scale", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
@@ -339,6 +367,9 @@ enum Failure {
|
||||
/// Whoever read stdout closed it (`wordcraft-cli commands --json | head`): there is nobody left
|
||||
/// to write to, so stop quietly and exit 0 like other command-line tools.
|
||||
StdoutClosed,
|
||||
/// `convert --strict` refused: the text for stderr (the report's lines, then its JSON line).
|
||||
/// Exit status 3.
|
||||
Strict(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Failure {
|
||||
@@ -346,6 +377,7 @@ impl fmt::Display for Failure {
|
||||
match self {
|
||||
Failure::Error(e) => f.write_str(e),
|
||||
Failure::StdoutClosed => f.write_str("stdout closed"),
|
||||
Failure::Strict(report) => f.write_str(report),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,11 +423,104 @@ fn valid_connect_addr(addr: &str) -> bool {
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
/// A failed `--strict` export: a strict refusal becomes [`Failure::Strict`] carrying what stderr
|
||||
/// shows (each line of the report as `wordcraft-cli: …`, then its JSON on one line, from
|
||||
/// `file.strictReport` for `page` of a page image, else the PDF); any other error is an ordinary
|
||||
/// failure.
|
||||
fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: Option<u64>) -> Failure {
|
||||
if !matches!(&e, wordcraft_engine::CmdError::Failed(m) if m.starts_with("strict: ")) {
|
||||
return engine_error(e);
|
||||
}
|
||||
let mut params = json!({"markup": markup});
|
||||
if let Some(page) = page {
|
||||
params["page"] = json!(page);
|
||||
}
|
||||
Failure::Strict(match s.run("file.strictReport", ¶ms).map_err(|e| e.to_string()) {
|
||||
Ok(json) => {
|
||||
let mut text = String::new();
|
||||
for line in report_lines(&json) {
|
||||
text += &format!("wordcraft-cli: {}\n", escape_peer(&line));
|
||||
}
|
||||
text + &json_line_safe(&json.to_string())
|
||||
}
|
||||
Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {}", escape_text(&e, false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The report's human lines, from its JSON (the engine's `StrictReport::lines`).
|
||||
fn report_lines(json: &Value) -> Vec<String> {
|
||||
wordcraft_engine::strict::lines_from_json(json)
|
||||
}
|
||||
|
||||
/// Explicitly update unlocked fields in a session before saving the document.
|
||||
///
|
||||
/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to
|
||||
/// stderr and continues conversion rather than aborting. Other command failures are converted to
|
||||
/// [`Failure`] via [`engine_error`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`.
|
||||
fn update_fields(s: &mut Session) -> Result<(), Failure> {
|
||||
if let Err(e) = s.run("references.updateFields", &json!({})) {
|
||||
if matches!(e, CmdError::Disabled(_)) {
|
||||
eprintln!("wordcraft-cli: warning: {e}");
|
||||
} else {
|
||||
return Err(engine_error(e));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
|
||||
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
|
||||
validate_options("convert", rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = match find_option_arg(rest, "--format") {
|
||||
OptionArg::Absent => None,
|
||||
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
|
||||
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
|
||||
OptionArg::Value(value) => Some(value),
|
||||
};
|
||||
let paths = positionals(rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
let strict = rest.iter().any(|a| a == "--strict");
|
||||
// Before the input is opened: a wrong output type is a usage error, not a file error.
|
||||
let to_png = o.to_ascii_lowercase().ends_with(".png");
|
||||
if strict && !(to_png || o.to_ascii_lowercase().ends_with(".pdf")) {
|
||||
return Err("convert: --strict needs a .pdf or .png output".into());
|
||||
}
|
||||
let password = password_from(rest)?;
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
if rest.iter().any(|a| a == "--update-fields") {
|
||||
update_fields(&mut s)?;
|
||||
}
|
||||
let r = if to_png {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
|
||||
} else {
|
||||
let mut params = json!({"path": o, "markup": markup, "strict": strict});
|
||||
if let Some(f) = format {
|
||||
params["format"] = json!(f);
|
||||
}
|
||||
s.run("file.save", ¶ms)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
return Err(if strict { refusal(&mut s, e, markup, to_png.then_some(1)) } else { engine_error(e) });
|
||||
}
|
||||
eprintln!("wrote {o}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
|
||||
match arg_value(rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => {
|
||||
let source = script_path.unwrap_or(Path::new(&path));
|
||||
Ok(Some(script::load(source)?))
|
||||
Ok(Some(script::load(source).map_err(engine_error)?))
|
||||
}
|
||||
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
|
||||
_ => Ok(None),
|
||||
@@ -427,42 +552,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
return write_stdout(|o| write!(o, "{USAGE}"));
|
||||
}
|
||||
match cmd {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = match find_option_arg(&rest, "--format") {
|
||||
OptionArg::Absent => None,
|
||||
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
|
||||
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
|
||||
OptionArg::Value(value) => Some(value),
|
||||
};
|
||||
let paths = positionals(&rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
if rest.iter().any(|a| a == "--update-fields") {
|
||||
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let r = if o.to_ascii_lowercase().ends_with(".png") {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
|
||||
} else {
|
||||
let mut params = json!({"path": o, "markup": markup});
|
||||
if let Some(f) = format {
|
||||
params["format"] = json!(f);
|
||||
}
|
||||
s.run("file.save", ¶ms)
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {o}");
|
||||
Ok(())
|
||||
}
|
||||
"convert" => run_convert(&rest, open),
|
||||
"info" => {
|
||||
validate_options("info", &rest, PASSWORD_OPTIONS)?;
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
|
||||
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(engine_error)?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{info}"))
|
||||
}
|
||||
"text" => {
|
||||
@@ -478,7 +574,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
|
||||
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(engine_error)?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{tree}"))
|
||||
}
|
||||
"render" => {
|
||||
@@ -492,7 +588,17 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
|
||||
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
|
||||
let strict = rest.iter().any(|a| a == "--strict");
|
||||
let mut params = json!({"path": o, "page": page, "scale": scale, "strict": strict});
|
||||
if strict {
|
||||
// The check and its report must see the page as it is written.
|
||||
params["markup"] = json!(false);
|
||||
}
|
||||
let r = match s.run("file.exportPng", ¶ms) {
|
||||
Ok(r) => r,
|
||||
Err(e) if strict => return Err(refusal(&mut s, e, false, Some(page))),
|
||||
Err(e) => return Err(engine_error(e)),
|
||||
};
|
||||
write_stdout(|o| writeln!(o, "{r}"))
|
||||
}
|
||||
"run" => {
|
||||
@@ -511,8 +617,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
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())?;
|
||||
s.close_document(first).map_err(|e| e.to_string())?;
|
||||
s.run("file.new", ¶ms).map_err(engine_error)?;
|
||||
s.close_document(first).map_err(engine_error)?;
|
||||
s
|
||||
}
|
||||
};
|
||||
@@ -521,7 +627,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
let print = rest.iter().any(|a| a == "--print");
|
||||
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
|
||||
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
|
||||
let r = s.run(id, params).map_err(|e| engine_error(format!("command {} ({id}): {e}", n + 1)))?;
|
||||
if print {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
@@ -531,10 +637,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
if rest.get(i).map(String::as_str) == Some("--cmd") {
|
||||
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
|
||||
let (id, params) = match spec.split_once('=') {
|
||||
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
|
||||
Some((id, p)) => {
|
||||
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
|
||||
}
|
||||
None => (spec.clone(), json!({})),
|
||||
};
|
||||
let r = s.run(&id, ¶ms).map_err(|e| format!("{id}: {e}"))?;
|
||||
let r = s.run(&id, ¶ms).map_err(|e| engine_error(format!("{id}: {e}")))?;
|
||||
if print {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
@@ -554,7 +662,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
return Err(format!("{out}: is a folder, not a file").into());
|
||||
}
|
||||
if !out.to_ascii_lowercase().ends_with(".png") {
|
||||
wordcraft_engine::io::save_bytes(out, &s.doc)?;
|
||||
wordcraft_engine::io::save_bytes(out, &s.doc).map_err(engine_error)?;
|
||||
}
|
||||
}
|
||||
for out in outputs {
|
||||
@@ -570,7 +678,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
} else {
|
||||
s.run("file.save", &json!({"path": out}))
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
r.map_err(engine_error)?;
|
||||
eprintln!("wrote {out}");
|
||||
}
|
||||
Ok(())
|
||||
@@ -669,10 +777,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let mut s = open(file, None)?;
|
||||
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
|
||||
let (id, params) = match spec.split_once('=') {
|
||||
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
|
||||
Some((id, p)) => {
|
||||
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
|
||||
}
|
||||
None => (spec.clone(), json!({})),
|
||||
};
|
||||
s.run(&id, ¶ms).map_err(|e| format!("{id}: {e}"))?;
|
||||
s.run(&id, ¶ms).map_err(|e| engine_error(format!("{id}: {e}")))?;
|
||||
}
|
||||
let mut opts = wordcraft_zotero::client::Options::default();
|
||||
if let Some(p) = arg_value(&rest, "--port") {
|
||||
@@ -689,13 +799,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
r
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(engine_error)?;
|
||||
for a in &host.alerts {
|
||||
eprintln!("Zotero: {}", escape_peer(a));
|
||||
}
|
||||
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
||||
if let Some(o) = arg_value(&rest, "--save") {
|
||||
s.run("file.save", &json!({"path": o})).map_err(|e| e.to_string())?;
|
||||
s.run("file.save", &json!({"path": o})).map_err(engine_error)?;
|
||||
eprintln!("wrote {o}");
|
||||
}
|
||||
Ok(())
|
||||
@@ -719,22 +829,62 @@ fn clip(s: &str) -> String {
|
||||
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
|
||||
/// terminal or pass for other output.
|
||||
fn escape_peer(s: &str) -> String {
|
||||
escape_text(s, true)
|
||||
}
|
||||
|
||||
/// Whether `c` must not reach a terminal as itself: a control character, a line or paragraph
|
||||
/// separator, a bidi control, or one that shows nothing (soft hyphen, zero-width characters, word
|
||||
/// joiners, the byte order mark, tag characters), which can hide or reorder what a line says.
|
||||
fn is_unsafe_char(c: char) -> bool {
|
||||
c.is_control()
|
||||
|| matches!(
|
||||
c,
|
||||
'\u{00ad}'
|
||||
| '\u{061c}'
|
||||
| '\u{200b}'..='\u{200f}'
|
||||
| '\u{2028}'..='\u{202e}'
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{2069}'
|
||||
| '\u{feff}'
|
||||
| '\u{e0000}'..='\u{e007f}'
|
||||
)
|
||||
}
|
||||
|
||||
/// [`escape_peer`], with backslashes escaped only when `backslash` (error messages quote Windows
|
||||
/// paths, and a backslash can't forge or hide anything).
|
||||
fn escape_text(s: &str, backslash: bool) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\\' if backslash => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
|
||||
out.extend(c.escape_unicode())
|
||||
}
|
||||
c if is_unsafe_char(c) => out.extend(c.escape_unicode()),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A JSON line safe to print: serde escapes control characters below U+0020 but not the other
|
||||
/// characters [`is_unsafe_char`] names, which a crafted file could put in a report. They become
|
||||
/// `\uXXXX` escapes (a surrogate pair above U+FFFF), so the line parses to the same value and
|
||||
/// can't drive or spoof a terminal.
|
||||
fn json_line_safe(json: &str) -> String {
|
||||
let mut out = String::with_capacity(json.len());
|
||||
for c in json.chars() {
|
||||
if is_unsafe_char(c) {
|
||||
for unit in c.encode_utf16(&mut [0u16; 2]) {
|
||||
out.push_str(&format!("\\u{unit:04x}"));
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parity_markdown(p: &Value) -> String {
|
||||
let mut s = String::from(
|
||||
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
|
||||
@@ -775,6 +925,10 @@ fn main() -> ExitCode {
|
||||
eprintln!("wordcraft-cli: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
Err(Failure::Strict(report)) => {
|
||||
eprintln!("{report}");
|
||||
ExitCode::from(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,10 @@ fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
|
||||
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
|
||||
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
|
||||
// Every command that opens a file reaches the open with it (no such file here), so
|
||||
@@ -228,6 +232,7 @@ fn only_the_opened_file_may_be_other_than_unicode() {
|
||||
for (raw, n) in [
|
||||
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
|
||||
(vec!["render".into(), "in.docx".into(), latin1()], 3),
|
||||
(vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3),
|
||||
(vec!["run".into(), "--save".into(), latin1()], 3),
|
||||
(vec!["commands".into(), latin1()], 2),
|
||||
] {
|
||||
@@ -559,3 +564,19 @@ fn script_parse_resolves_paths_against_the_base() {
|
||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||
assert_eq!(s.commands.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_characters_are_escaped_and_json_lines_still_parse_back() {
|
||||
let hidden = "a\u{ad}b\u{200b}c\u{200d}d\u{2060}e\u{2064}f\u{feff}g\u{e0041}h\u{e007f}i\u{85}j\u{202e}k";
|
||||
let shown = escape_peer(hidden);
|
||||
assert!(!shown.chars().any(is_unsafe_char), "{shown}");
|
||||
assert!(shown.contains("\\u{e0041}") && shown.contains("\\u{ad}"), "{shown}");
|
||||
// Errors keep a Windows path readable.
|
||||
assert_eq!(escape_text("C:\\dir\\a.docx", false), "C:\\dir\\a.docx");
|
||||
|
||||
let value = json!({"part": hidden, "plain": "ünïcode ok"});
|
||||
let line = json_line_safe(&value.to_string());
|
||||
assert!(!line.chars().any(is_unsafe_char), "{line}");
|
||||
assert!(line.contains("\\ud800") || line.contains("\\udb40"), "{line}");
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), value);
|
||||
}
|
||||
|
||||
@@ -47,3 +47,65 @@ fn convert_refresh_is_opt_in() {
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Converting a protected document with `--update-fields` skips updating fields with a warning,
|
||||
/// retains the cached field result, and preserves the protection mode on output.
|
||||
#[test]
|
||||
fn convert_protected_document_with_update_fields_skips_with_warning() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-convert-fields-prot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
for (mode, expected_warning) in [("readOnly", "protected"), ("forms", "protected"), ("comments", "comments")] {
|
||||
let mut doc = Document::from_text("Contents\nTarget");
|
||||
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
|
||||
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
|
||||
doc.insert_object(
|
||||
&Pos::body(0, 0),
|
||||
InlineObject::Field {
|
||||
instr: "PAGEREF target".into(),
|
||||
result: "104".into(),
|
||||
locked: false,
|
||||
dirty: true,
|
||||
code: false,
|
||||
form: String::new(),
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
doc.settings.protection = Some(mode.into());
|
||||
let input = dir.join(format!("in_{mode}.json"));
|
||||
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let docx = dir.join(format!("in_{mode}.docx"));
|
||||
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
|
||||
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
|
||||
|
||||
let output = dir.join(format!("out_{mode}.json"));
|
||||
let _ = std::fs::remove_file(&output);
|
||||
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cmd.arg("convert").arg(&docx).arg(&output).arg("--update-fields");
|
||||
let run = cmd.output().unwrap();
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(run.status.success(), "exit status was failure for mode {mode}: {stderr}");
|
||||
assert!(stderr.contains("warning:"), "stderr missing warning for mode {mode}: {stderr}");
|
||||
assert!(stderr.contains(expected_warning), "stderr missing '{expected_warning}' for mode {mode}: {stderr}");
|
||||
assert!(output.exists(), "output file was not created for mode {mode}");
|
||||
|
||||
let out: Document = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap();
|
||||
let results: Vec<_> = out
|
||||
.para(StoryRef::Body, &Path::top(0))
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Result should remain "104" (cached) because the field could not be updated under protection
|
||||
assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}");
|
||||
assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}");
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ mod help;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod non_utf8_args;
|
||||
mod picture_then_typing;
|
||||
mod strict;
|
||||
mod text_tracked;
|
||||
|
||||
@@ -53,6 +53,15 @@ fn the_file_a_command_opens_may_have_any_name() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_before_the_input_keeps_its_native_name() {
|
||||
let (dir, file) = folder("option-first");
|
||||
let saved = dir.join("out.xml");
|
||||
ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()]));
|
||||
assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_such_argument_is_an_error_naming_it() {
|
||||
let (dir, file) = folder("error");
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
//! `convert --strict` (#6005): exit 3 and a report when a PDF or PNG would silently lack a
|
||||
//! character or a picture, nothing written; exit 0 otherwise.
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_fonts::FontDb;
|
||||
|
||||
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
|
||||
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wc-cli-strict-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn convert(input: &Path, output: &Path, strict: bool) -> Output {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cmd.arg("convert").arg(input).arg(output);
|
||||
if strict {
|
||||
cmd.arg("--strict");
|
||||
}
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
fn stderr(o: &Output) -> String {
|
||||
String::from_utf8_lossy(&o.stderr).into_owned()
|
||||
}
|
||||
|
||||
/// The report's JSON line: the last line of stderr.
|
||||
fn report_json(o: &Output) -> serde_json::Value {
|
||||
let text = stderr(o);
|
||||
let last = text.lines().last().unwrap_or_default();
|
||||
serde_json::from_str(last).unwrap_or_else(|e| panic!("last stderr line is not JSON ({e}): {text}"))
|
||||
}
|
||||
|
||||
fn json_input(d: &Path, text: &str) -> PathBuf {
|
||||
let path = d.join("in.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&Document::from_text(text)).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// Built without craft-fonts, the host's own fonts decide what is drawable: skip, as the layout
|
||||
/// tests do.
|
||||
fn without_fonts() -> bool {
|
||||
let missing = option_env!("CRAFT_FONTS_DIR").is_none_or(str::is_empty);
|
||||
if missing {
|
||||
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// A code point in unassigned plane 4 that no face of the host covers.
|
||||
fn uncovered() -> char {
|
||||
let db = FontDb::global();
|
||||
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
|
||||
(0x40000u32..0x4FFF0)
|
||||
.filter_map(char::from_u32)
|
||||
.find(|c| !primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none())
|
||||
.expect("the host has faces covering all of plane 4")
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn pic(rid: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A DOCX whose header holds a picture of `header_media` (`word/media/image2.bin`).
|
||||
fn docx_with_header_picture(header_media: &[u8]) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<w:document {NS}><w:body><w:p><w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let drels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/></Relationships>"#);
|
||||
let hrels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#);
|
||||
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
|
||||
package(
|
||||
&doc,
|
||||
&drels,
|
||||
&[("word/header2.xml", hdr.as_bytes()), ("word/_rels/header2.xml.rels", hrels.as_bytes()), ("word/media/image2.bin", header_media)],
|
||||
)
|
||||
}
|
||||
|
||||
/// A DOCX whose body picture points outside the file, at `target`.
|
||||
fn docx_with_external_picture(target: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p></w:body></w:document>"#, pic("rIdX"));
|
||||
let drels = format!(
|
||||
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdX" Type="{REL}/image" Target="{target}" TargetMode="External"/></Relationships>"#
|
||||
);
|
||||
package(&doc, &drels, &[])
|
||||
}
|
||||
|
||||
fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
|
||||
);
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut parts: Vec<(&str, &[u8])> = vec![
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("word/document.xml", document.as_bytes()),
|
||||
("word/_rels/document.xml.rels", document_rels.as_bytes()),
|
||||
];
|
||||
parts.extend_from_slice(extra);
|
||||
for (name, bytes) in parts {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multilingual_names_pass_strict() {
|
||||
if without_fonts() {
|
||||
return;
|
||||
}
|
||||
let names = "Łukasz Żmijewski\nGheorghe Ștefănescu\nKrejčí\nŞahın Gül\nLlŷr Grŵn\n王小明\n김민수\nสมชาย ใจดี\nराम शर्मा";
|
||||
let d = dir("names");
|
||||
let input = json_input(&d, names);
|
||||
for out in ["names.pdf", "names.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out.exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uncovered_code_point_fails_strict_with_a_report_and_no_file() {
|
||||
let c = uncovered();
|
||||
let cp = format!("U+{:04X}", c as u32);
|
||||
let d = dir("uncovered");
|
||||
let input = json_input(&d, &format!("a{c}b{c}{c}"));
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×3)")), "{text}");
|
||||
let json = report_json(&r);
|
||||
assert_eq!(json["schema"], 1);
|
||||
assert_eq!(json["strict"], "failed");
|
||||
assert_eq!(json["missing_glyphs"][0]["cp"], cp.as_str());
|
||||
assert_eq!(json["missing_glyphs"][0]["count"], 3);
|
||||
assert!(!out.exists(), "{} was written", out.display());
|
||||
eprintln!("{text}");
|
||||
// Without --strict the same input converts, as before.
|
||||
let r = convert(&input, &out, false);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out.exists());
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_png_in_a_header_passes_strict() {
|
||||
let d = dir("header-png");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(&png())).unwrap();
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out.exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_header_picture_fails_strict_naming_the_part_and_the_header() {
|
||||
let d = dir("header-corrupt");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains("word/media/image2.bin (header2.xml): unsupported format"), "{text}");
|
||||
let json = report_json(&r);
|
||||
assert_eq!(json["strict"], "failed");
|
||||
assert_eq!(
|
||||
json["missing_pictures"][0],
|
||||
serde_json::json!({"part": "word/media/image2.bin", "source_part": "word/header2.xml", "reason": "unsupported_format"})
|
||||
);
|
||||
assert!(!out.exists());
|
||||
eprintln!("{text}");
|
||||
}
|
||||
// Without --strict the grey box is drawn, as before.
|
||||
let out = d.join("lenient.pdf");
|
||||
assert_eq!(convert(&input, &out, false).status.code(), Some(0));
|
||||
assert!(out.exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_external_picture_target_is_never_fetched_and_is_reported_under_strict() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let target = format!("http://{}/canary.png", listener.local_addr().unwrap());
|
||||
let d = dir("external");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_external_picture(&target)).unwrap();
|
||||
|
||||
let lenient = convert(&input, &d.join("lenient.pdf"), false);
|
||||
assert_eq!(lenient.status.code(), Some(0), "{}", stderr(&lenient));
|
||||
let out = d.join("strict.pdf");
|
||||
let strict = convert(&input, &out, true);
|
||||
assert_eq!(strict.status.code(), Some(3), "{}", stderr(&strict));
|
||||
let json = report_json(&strict);
|
||||
assert_eq!(json["missing_pictures"][0]["reason"], "external_target");
|
||||
assert!(stderr(&strict).contains("external target"));
|
||||
assert!(!out.exists());
|
||||
|
||||
match listener.accept() {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Ok((_, from)) => panic!("the converter connected to the external target from {from}"),
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_exits_1_with_the_usual_message_with_and_without_strict() {
|
||||
let d = dir("bad-input");
|
||||
let good = docx_with_header_picture(&png());
|
||||
let truncated = d.join("truncated.docx");
|
||||
std::fs::write(&truncated, &good[..good.len() / 2]).unwrap();
|
||||
let not_zip = d.join("not-zip.docx");
|
||||
std::fs::write(¬_zip, b"this is plainly not a zip archive").unwrap();
|
||||
for input in [&truncated, ¬_zip] {
|
||||
let plain = convert(input, &d.join("a.pdf"), false);
|
||||
let strict = convert(input, &d.join("b.pdf"), true);
|
||||
assert_eq!(plain.status.code(), Some(1), "{}", stderr(&plain));
|
||||
assert_eq!(strict.status.code(), Some(1), "{}", stderr(&strict));
|
||||
assert_eq!(stderr(&plain), stderr(&strict));
|
||||
assert!(stderr(&plain).starts_with("wordcraft-cli: "), "{}", stderr(&plain));
|
||||
assert!(!d.join("a.pdf").exists() && !d.join("b.pdf").exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_with_another_output_type_exits_1_before_the_input_is_read() {
|
||||
let d = dir("wrong-output");
|
||||
let missing = d.join("does-not-exist.docx");
|
||||
for out in ["out.docx", "out.txt", "out.odt"] {
|
||||
let r = convert(&missing, &d.join(out), true);
|
||||
assert_eq!(r.status.code(), Some(1));
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains("--strict needs a .pdf or .png output"), "{text}");
|
||||
assert!(!d.join(out).exists());
|
||||
}
|
||||
// Without --strict the same command fails on the missing input.
|
||||
let r = convert(&missing, &d.join("out.docx"), false);
|
||||
assert_eq!(r.status.code(), Some(1));
|
||||
assert!(!stderr(&r).contains("--strict"));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
fn render(input: &Path, output: &Path, args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("render").arg(input).arg(output).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// Page 1 has `a{c}b`, page 2 plain text.
|
||||
fn two_page_input(d: &Path, text: &str) -> PathBuf {
|
||||
let mut doc = Document::from_text(&format!("{text}\nSecond page"));
|
||||
doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props.page_break_before = Some(true);
|
||||
let path = d.join("two.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_strict_refuses_an_uncovered_character_on_the_page_with_no_stdout_or_file() {
|
||||
let c = uncovered();
|
||||
let cp = format!("U+{:04X}", c as u32);
|
||||
let d = dir("render-glyph");
|
||||
let input = two_page_input(&d, &format!("a{c}b"));
|
||||
let out = d.join("p1.png");
|
||||
let r = render(&input, &out, &["--strict"]);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert!(r.stdout.is_empty(), "{}", String::from_utf8_lossy(&r.stdout));
|
||||
assert!(!out.exists());
|
||||
let text = stderr(&r);
|
||||
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×1)")), "{text}");
|
||||
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
|
||||
eprintln!("{text}");
|
||||
|
||||
// The character is not on page 2, and --scale does not matter to the check.
|
||||
let out2 = d.join("p2.png");
|
||||
let r = render(&input, &out2, &["--page", "2", "--strict", "--scale", "0.5"]);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out2.exists());
|
||||
assert!(String::from_utf8_lossy(&r.stdout).contains("\"path\""));
|
||||
// And the same page 1 without --strict renders, with the usual stdout.
|
||||
let out3 = d.join("lenient.png");
|
||||
let r = render(&input, &out3, &[]);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(out3.exists());
|
||||
let json: serde_json::Value = serde_json::from_slice(&r.stdout).unwrap();
|
||||
assert_eq!(json["path"], out3.to_string_lossy().as_ref());
|
||||
assert!(json["width"].as_u64().is_some() && json["bytes"].as_u64().is_some());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_strict_refuses_a_corrupt_header_picture_and_bad_input_still_exits_1() {
|
||||
let d = dir("render-pic");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
|
||||
let out = d.join("out.png");
|
||||
let r = render(&input, &out, &["--strict"]);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert!(r.stdout.is_empty());
|
||||
assert!(stderr(&r).contains("word/media/image2.bin (header2.xml)"), "{}", stderr(&r));
|
||||
assert!(!out.exists());
|
||||
|
||||
let bad = d.join("bad.docx");
|
||||
std::fs::write(&bad, b"this is plainly not a zip archive").unwrap();
|
||||
let (plain, strict) = (render(&bad, &out, &[]), render(&bad, &out, &["--strict"]));
|
||||
assert_eq!((plain.status.code(), strict.status.code()), (Some(1), Some(1)));
|
||||
assert_eq!(stderr(&plain), stderr(&strict));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_target_with_a_newline_and_a_bidi_override_cannot_forge_a_report_line() {
|
||||
let d = dir("escape");
|
||||
let input = d.join("in.docx");
|
||||
// XML character references put a newline and U+202E in the target.
|
||||
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a wordcraft-cli: strict: forged‮gnp.png")).unwrap();
|
||||
let out = d.join("out.pdf");
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let text = stderr(&r);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// One human line for the picture, then the JSON line.
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].starts_with("wordcraft-cli: strict: 1 picture(s) left out: "), "{text}");
|
||||
assert!(lines[0].contains("\\nwordcraft-cli: strict: forged\\u{202e}gnp.png"), "{text}");
|
||||
assert!(!text.contains('\u{202e}'), "the raw bidi override reached the terminal");
|
||||
// The JSON keeps the raw value, escaped by the JSON encoder.
|
||||
let json = report_json(&r);
|
||||
let part = json["missing_pictures"][0]["part"].as_str().unwrap();
|
||||
assert!(part.contains('\n') && part.contains('\u{202e}'), "{part:?}");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_with_strict_cannot_forge_an_error_line_either() {
|
||||
let d = dir("run-escape");
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a wordcraft-cli: strict: forged[2J‮gnp.png")).unwrap();
|
||||
let out = d.join("out.pdf");
|
||||
let cmd = format!("file.exportPdf={}", serde_json::json!({"path": out, "strict": true}));
|
||||
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("run").arg("--file").arg(&input).arg("--cmd").arg(cmd).output().unwrap();
|
||||
assert_eq!(r.status.code(), Some(1));
|
||||
let text = stderr(&r);
|
||||
assert_eq!(text.lines().count(), 1, "{text}");
|
||||
assert!(text.contains("\\u{1b}") || text.contains("\\u{1b}[2J"), "{text}");
|
||||
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
|
||||
assert!(!out.exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_errors_keep_their_line_breaks() {
|
||||
// Errors go to stderr with the usage after a blank line (`missing argument`, `unknown command`).
|
||||
for args in [&["frobnicate"][..], &["convert"][..]] {
|
||||
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap();
|
||||
let text = stderr(&r);
|
||||
assert_eq!(r.status.code(), Some(1), "{args:?}: {text}");
|
||||
assert!(text.lines().count() > 3, "{args:?}: {text}");
|
||||
assert!(text.lines().any(|l| l == "USAGE:"), "{args:?}: {text}");
|
||||
assert!(!text.contains("\\n"), "{args:?}: {text}");
|
||||
}
|
||||
// No arguments at all prints the usage as help, on stdout.
|
||||
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).output().unwrap();
|
||||
let all = format!("{}{}", String::from_utf8_lossy(&r.stdout), stderr(&r));
|
||||
assert!(all.lines().count() > 3 && all.lines().any(|l| l == "USAGE:"), "{all}");
|
||||
}
|
||||
|
||||
/// A minimal EMF (88-byte header, 100 mm square frame) with one EMR_EXTTEXTOUTW record showing
|
||||
/// `text` in the default font.
|
||||
fn emf_with_text(text: &str) -> Vec<u8> {
|
||||
let units: Vec<u16> = text.encode_utf16().collect();
|
||||
let mut rec = vec![0u8; 76];
|
||||
rec[0..4].copy_from_slice(&84u32.to_le_bytes());
|
||||
rec[36..40].copy_from_slice(&10i32.to_le_bytes());
|
||||
rec[40..44].copy_from_slice(&40i32.to_le_bytes());
|
||||
rec[44..48].copy_from_slice(&(units.len() as u32).to_le_bytes());
|
||||
rec[48..52].copy_from_slice(&76u32.to_le_bytes());
|
||||
rec.extend(units.iter().flat_map(|u| u.to_le_bytes()));
|
||||
while !rec.len().is_multiple_of(4) {
|
||||
rec.push(0);
|
||||
}
|
||||
let len = rec.len() as u32;
|
||||
rec[4..8].copy_from_slice(&len.to_le_bytes());
|
||||
let eof: Vec<u8> = [14u32.to_le_bytes(), 20u32.to_le_bytes(), [0; 4], [0; 4], 20u32.to_le_bytes()].concat();
|
||||
let total = 88 + rec.len() + eof.len();
|
||||
let mut h = vec![0u8; 88];
|
||||
h[0..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
h[4..8].copy_from_slice(&88u32.to_le_bytes());
|
||||
h[8..24].copy_from_slice(&[0i32, 0, 100, 100].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
|
||||
h[24..40].copy_from_slice(&[0i32, 0, 10000, 10000].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
|
||||
h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
|
||||
h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes());
|
||||
h[48..52].copy_from_slice(&(total as u32).to_le_bytes());
|
||||
h[52..56].copy_from_slice(&2u32.to_le_bytes());
|
||||
h[56..58].copy_from_slice(&1u16.to_le_bytes());
|
||||
h[72..76].copy_from_slice(&1000i32.to_le_bytes());
|
||||
h[76..80].copy_from_slice(&1000i32.to_le_bytes());
|
||||
h[80..84].copy_from_slice(&250i32.to_le_bytes());
|
||||
h[84..88].copy_from_slice(&250i32.to_le_bytes());
|
||||
[h, rec, eof].concat()
|
||||
}
|
||||
|
||||
/// The text a metafile picture draws is checked like the page's own: a character no face covers
|
||||
/// refuses the export from `convert` and `render`, naming the code point, and nothing is written.
|
||||
#[test]
|
||||
fn a_character_in_a_metafile_pictures_text_fails_strict_like_the_pages_own() {
|
||||
if without_fonts() {
|
||||
return;
|
||||
}
|
||||
let c = uncovered();
|
||||
let cp = format!("U+{:04X}", c as u32);
|
||||
let d = dir("emf-text");
|
||||
let emf = emf_with_text(&format!("ab{c}"));
|
||||
let input = d.join("in.docx");
|
||||
std::fs::write(&input, docx_with_header_picture(&emf)).unwrap();
|
||||
for out in ["out.pdf", "out.png"] {
|
||||
let out = d.join(out);
|
||||
let r = convert(&input, &out, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str(), "{}", stderr(&r));
|
||||
assert!(!out.exists(), "{} was written", out.display());
|
||||
// Without --strict it converts, the character a hollow box.
|
||||
let r = convert(&input, &out, false);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
std::fs::remove_file(&out).unwrap();
|
||||
}
|
||||
let out = d.join("p1.png");
|
||||
let r = render(&input, &out, &["--strict"]);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
assert!(r.stdout.is_empty() && !out.exists());
|
||||
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
|
||||
// Text the faces cover passes.
|
||||
std::fs::write(&input, docx_with_header_picture(&emf_with_text("abc"))).unwrap();
|
||||
let r = convert(&input, &d.join("ok.pdf"), true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// The page raster leaves out a metafile page background (it only draws raster ones), so a PNG
|
||||
/// would silently lack it: strict refuses that output, and a PDF, which draws it as vector, passes.
|
||||
#[test]
|
||||
fn a_metafile_page_background_fails_strict_for_png_but_not_for_pdf() {
|
||||
if without_fonts() {
|
||||
return;
|
||||
}
|
||||
let d = dir("emf-background");
|
||||
let mut doc = Document::from_text("text");
|
||||
doc.media.insert("bg.emf".into(), std::sync::Arc::new(emf_with_text("abc")));
|
||||
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.emf".into(), fill_type: "frame".into(), title: String::new() });
|
||||
let input = d.join("in.json");
|
||||
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let png = d.join("out.png");
|
||||
let r = convert(&input, &png, true);
|
||||
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
|
||||
let json = report_json(&r);
|
||||
assert_eq!(json["missing_pictures"][0]["part"], "bg.emf", "{}", stderr(&r));
|
||||
assert_eq!(json["missing_pictures"][0]["source_part"], "background");
|
||||
assert_eq!(json["missing_pictures"][0]["reason"], "unsupported_format");
|
||||
assert!(!png.exists());
|
||||
let pdf = d.join("out.pdf");
|
||||
let r = convert(&input, &pdf, true);
|
||||
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
|
||||
assert!(pdf.exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -17,7 +17,9 @@ path = "src/main.rs"
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
|
||||
//! when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Value>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply,
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -76,7 +76,8 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
|
||||
@@ -64,6 +64,11 @@ wl-clipboard-rs = "0.9"
|
||||
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
|
||||
# through arboard, at this version.
|
||||
x11rb = "0.13"
|
||||
# Check that the libraries winit's X11 backend opens are installed before choosing XWayland (see
|
||||
# `x11_libraries` in main.rs). Both are already in the build through winit's `x11` feature, at
|
||||
# these versions and with this feature.
|
||||
x11-dl = "2.21"
|
||||
xkbcommon-dl = { version = "0.4.2", features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! An AppImage's environment inherited from another program (#5653).
|
||||
//!
|
||||
//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where
|
||||
//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage
|
||||
//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`,
|
||||
//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft.
|
||||
//!
|
||||
//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables
|
||||
//! are another program's, and the process starts over without them, before anything reads them.
|
||||
//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the
|
||||
//! arguments and the standard streams.)
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io::Write;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// What the AppImage runtime sets; the same list as the documented workaround on the issue.
|
||||
const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"];
|
||||
|
||||
/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not:
|
||||
/// the update then goes through the package or the plain file this copy really is, and at worst
|
||||
/// says it can't, where the other answer would overwrite a file that isn't WordCraft's.
|
||||
fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool {
|
||||
let (Some(exe), Some(appdir)) = (exe, appdir) else { return false };
|
||||
let appdir = Path::new(appdir);
|
||||
if !appdir.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
let Ok(appdir) = appdir.canonicalize() else { return false };
|
||||
// No image mounts at the root, where every program on the machine would count as inside it.
|
||||
appdir.parent().is_some() && exe.starts_with(&appdir)
|
||||
}
|
||||
|
||||
/// Starts this process over without another program's AppImage variables; returns only when there
|
||||
/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts.
|
||||
pub(crate) fn drop_inherited() -> Option<std::io::Error> {
|
||||
let appimage = std::env::var_os("APPIMAGE")?;
|
||||
let exe = std::env::current_exe().ok();
|
||||
if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) {
|
||||
return None;
|
||||
}
|
||||
// The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics.
|
||||
let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage));
|
||||
let mut args = std::env::args_os();
|
||||
// This file, whatever has happened to its path since it started.
|
||||
let mut again = Command::new("/proc/self/exe");
|
||||
if let Some(arg0) = args.next() {
|
||||
again.arg0(arg0);
|
||||
}
|
||||
again.args(args);
|
||||
for var in RUNTIME_VARS {
|
||||
again.env_remove(var);
|
||||
}
|
||||
Some(again.exec())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::runs_from_image;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A folder of its own for one test, removed when it ends.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
/// An empty folder named after `test` and this process, made afresh.
|
||||
fn new(test: &str) -> Tmp {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Tmp(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
/// Removes the folder and what the test left in it.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A mount with the executable inside it, by real path (`/tmp` may be a link).
|
||||
fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
|
||||
let dir = dir.canonicalize().unwrap();
|
||||
let exe = dir.join("usr/bin/wordcraft");
|
||||
(dir, exe)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_executable_in_the_mount_is_the_image() {
|
||||
let tmp = Tmp::new("own");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
assert!(runs_from_image(Some(&exe), Some(dir.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_to_the_mount_is_the_same_mount() {
|
||||
let tmp = Tmp::new("link");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
let link = tmp.0.join("link");
|
||||
std::os::unix::fs::symlink(&dir, &link).unwrap();
|
||||
assert!(runs_from_image(Some(&exe), Some(link.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_executable_is_not_in_another_programs_mount() {
|
||||
let tmp = Tmp::new("other");
|
||||
let (other, _) = mount(&tmp.0, ".mount_other");
|
||||
assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str())));
|
||||
}
|
||||
|
||||
/// Paths compare by component: one mount's name can start with another's.
|
||||
#[test]
|
||||
fn a_mount_whose_name_starts_the_same_is_another_mount() {
|
||||
let tmp = Tmp::new("prefix");
|
||||
let (other, _) = mount(&tmp.0, ".mount_ab");
|
||||
let (_, exe) = mount(&tmp.0, ".mount_abc");
|
||||
assert!(!runs_from_image(Some(&exe), Some(other.as_os_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_unclear_is_not_the_image() {
|
||||
let tmp = Tmp::new("unclear");
|
||||
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
|
||||
// No `APPDIR`, one that's gone (the other program has exited) or empty.
|
||||
assert!(!runs_from_image(Some(&exe), None));
|
||||
assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str())));
|
||||
assert!(!runs_from_image(Some(&exe), Some("".as_ref())));
|
||||
// The root, and a relative path that happens to lead here.
|
||||
assert!(!runs_from_image(Some(&exe), Some("/".as_ref())));
|
||||
assert!(!runs_from_image(Some(&exe), Some(".".as_ref())));
|
||||
// No executable path at all.
|
||||
assert!(!runs_from_image(None, Some(dir.as_os_str())));
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,15 @@ impl AppleEvents {
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame. A document
|
||||
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
|
||||
/// is already open, by showing it. Quitting closes the window, which asks about every document
|
||||
/// is already open, by showing it; a Word template starts a new document based on it, as Word's
|
||||
/// default verb for one does (#3433). Quitting closes the window, which asks about every document
|
||||
/// with unsaved changes.
|
||||
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
match app.open_file(p, None) {
|
||||
match app.open_from_platform(p, None) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
|
||||
@@ -108,8 +108,8 @@ pub fn formats() -> Vec<String> {
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are
|
||||
/// checked from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -136,6 +136,10 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
|
||||
if let Some(picture) = platform::picture() {
|
||||
return Some(picture);
|
||||
}
|
||||
match clipboard.get_image() {
|
||||
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
|
||||
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
|
||||
@@ -160,6 +164,7 @@ mod platform {
|
||||
use std::io::Read;
|
||||
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
@@ -343,6 +348,22 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
|
||||
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
|
||||
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
|
||||
/// (or isn't reachable): arboard is asked then, which also covers X11.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
|
||||
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
|
||||
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
|
||||
/// A connection that sends no authorised request in this time is closed.
|
||||
const FIRST_REQUEST: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a caller waits for the UI to answer a request. A request still queued after this
|
||||
/// is cancelled and never runs.
|
||||
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Connections that have not yet sent an authorised request are limited to this many; more are
|
||||
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
|
||||
const MAX_UNAUTHENTICATED: usize = 64;
|
||||
|
||||
/// Counts one unauthenticated connection until it authenticates or ends.
|
||||
struct Admission(Option<Arc<AtomicUsize>>);
|
||||
|
||||
impl Admission {
|
||||
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
|
||||
if count.fetch_add(1, Ordering::SeqCst) >= max {
|
||||
count.fetch_sub(1, Ordering::SeqCst);
|
||||
return None;
|
||||
}
|
||||
Some(Self(Some(count.clone())))
|
||||
}
|
||||
fn release(&mut self) {
|
||||
if let Some(c) = self.0.take() {
|
||||
c.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Admission {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
|
||||
pub struct KeyFile(PathBuf);
|
||||
|
||||
@@ -37,6 +70,15 @@ impl KeyFile {
|
||||
/// cannot be bound or there is no random key (the control channel is off); the key file is
|
||||
/// `None` when it could not be written (requests are then refused).
|
||||
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
|
||||
}
|
||||
|
||||
fn start_with(
|
||||
port: u16,
|
||||
ctx: egui::Context,
|
||||
settings: Option<&Path>,
|
||||
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
|
||||
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
|
||||
None
|
||||
}
|
||||
};
|
||||
let rx = match serve(listener, ctx, key) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
if let Some(f) = &key_file {
|
||||
f.remove();
|
||||
}
|
||||
log::error!("control server off: cannot start the accept thread ({e})");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match &key_file {
|
||||
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
|
||||
None => log::info!("control server listening on 127.0.0.1:{port}"),
|
||||
}
|
||||
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
|
||||
Some((rx, key_file))
|
||||
}
|
||||
|
||||
/// A new key: 32 random bytes from the operating system, as hex.
|
||||
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
|
||||
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
|
||||
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
|
||||
}
|
||||
|
||||
/// The time and count budgets of a server (small ones in tests).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
first: Duration,
|
||||
wait: Duration,
|
||||
max_unauthenticated: usize,
|
||||
}
|
||||
|
||||
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
|
||||
}
|
||||
|
||||
fn serve_with_spawner(
|
||||
listener: TcpListener,
|
||||
ctx: egui::Context,
|
||||
key: String,
|
||||
limits: Limits,
|
||||
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
|
||||
) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let key: Arc<str> = key.into();
|
||||
std::thread::spawn(move || {
|
||||
let unauthenticated = Arc::new(AtomicUsize::new(0));
|
||||
let accept = move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
|
||||
log::warn!("control server: too many unauthenticated connections, one closed");
|
||||
continue;
|
||||
};
|
||||
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
|
||||
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
|
||||
// A thread that cannot start closes this connection only (the guard releases its slot).
|
||||
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
|
||||
log::warn!("control server: no thread for a connection: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
rx
|
||||
};
|
||||
spawn(Box::new(accept))?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// The read side of a connection with an optional overall deadline: each read waits only for
|
||||
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
// One overall deadline from accept to the first authorised request; after it, none.
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
|
||||
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
|
||||
let mut authorised = false;
|
||||
// The key an `auth` request gave this connection.
|
||||
let mut conn_key: Option<String> = None;
|
||||
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
if !authorised {
|
||||
authorised = true;
|
||||
admission.release();
|
||||
reader.get_mut().set_deadline(None);
|
||||
}
|
||||
if method == "auth" {
|
||||
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
|
||||
if waiter.cancel() {
|
||||
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
|
||||
}
|
||||
});
|
||||
if !reply(r) {
|
||||
break;
|
||||
}
|
||||
@@ -260,7 +350,7 @@ mod tests {
|
||||
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
|
||||
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
|
||||
}
|
||||
|
||||
fn boot() -> (u16, Receiver<ControlRequest>) {
|
||||
@@ -512,6 +602,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
|
||||
let dir = temp_dir("spawn-failure");
|
||||
let path = wordcraft_control_key::key_path(&dir, 0);
|
||||
let mut bound = None;
|
||||
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
|
||||
bound = Some(listener.local_addr().unwrap());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
|
||||
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
|
||||
drop(accept);
|
||||
Err(std::io::Error::other("injected accept thread spawn failure"))
|
||||
})
|
||||
});
|
||||
assert!(result.is_none(), "a server without its accept thread must not report success");
|
||||
assert!(!path.exists(), "failed startup must remove its published key");
|
||||
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_json_request_at_eof_can_reach_the_ui() {
|
||||
// This is why a bridge write failure before the newline is outcome-unknown.
|
||||
let (port, rx) = boot();
|
||||
let mut c = Conn::open(port);
|
||||
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
|
||||
c.1.write_all(body.to_string().as_bytes()).unwrap();
|
||||
c.1.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let req = answer(&rx);
|
||||
assert_eq!(req.params["params"]["text"], "once");
|
||||
assert_eq!(c.recv()["ok"], true);
|
||||
assert!(c.closed());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_key_file_is_replaced() {
|
||||
let dir = temp_dir("stale");
|
||||
@@ -532,4 +656,52 @@ mod tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
|
||||
}
|
||||
|
||||
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
|
||||
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_timed_out_in_the_queue_never_runs() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
|
||||
// The UI gets to it late: it must not run it.
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(!req.start(), "a cancelled request was started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
|
||||
let (port, rx) = boot_limits(small(150, 8));
|
||||
let mut c = Conn::open(port);
|
||||
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(req.start());
|
||||
let r = c.recv();
|
||||
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_connections_are_limited() {
|
||||
let (port, rx) = boot_limits(small(5_000, 2));
|
||||
let mut idle = [Conn::open(port), Conn::open(port)];
|
||||
// Give the server time to count both.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
|
||||
// One authenticates: its slot is free again.
|
||||
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let mut again = Conn::open(port);
|
||||
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+113
-28
@@ -10,6 +10,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod animations;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod clipboard;
|
||||
@@ -67,6 +69,8 @@ impl eframe::App for App {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
let b = self.0.window_builder().clone().with_inner_size(size);
|
||||
self.0.set_window_builder(b);
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
@@ -129,6 +133,7 @@ impl eframe::App for App {
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
}
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +193,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
}
|
||||
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
|
||||
app.start_pending_update();
|
||||
dispatch2::DispatchQueue::main().exec_async(|| {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
|
||||
@@ -269,9 +276,16 @@ fn saved_ui() -> Option<UiState> {
|
||||
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
|
||||
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
|
||||
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
|
||||
/// available falls back to the other instead of failing to start.
|
||||
/// available falls back to the other instead of failing to start. The X11 libraries are only
|
||||
/// recommended by the packages, so when `x11_libraries` reports some missing (it's asked only when
|
||||
/// X11 would be forced) the app stays on Wayland: forced X11 without them failed to start, and
|
||||
/// winit panicked without libxkbcommon-x11.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
|
||||
fn uses_x11(
|
||||
setting: wordcraft_ui_egui::DisplayServer,
|
||||
env: impl Fn(&str) -> Option<std::ffi::OsString>,
|
||||
x11_libraries: impl FnOnce() -> Result<(), String>,
|
||||
) -> (bool, String) {
|
||||
use wordcraft_ui_egui::DisplayServer;
|
||||
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
|
||||
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
|
||||
@@ -281,39 +295,60 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
|
||||
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
|
||||
None => (setting, "the setting"),
|
||||
};
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
|
||||
let missing = if wanted != DisplayServer::Wayland && set("DISPLAY") { x11_libraries().err() } else { None };
|
||||
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY") && missing.is_none();
|
||||
let wayland = set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET");
|
||||
let runs = if x11 {
|
||||
"X11"
|
||||
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
} else if wayland {
|
||||
"native Wayland"
|
||||
} else if set("DISPLAY") {
|
||||
"X11 (no Wayland)"
|
||||
} else {
|
||||
"neither (no Wayland or X server found)"
|
||||
};
|
||||
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
|
||||
let mut why = format!("display: {runs}; {} asks for {}", by, wanted.code());
|
||||
if let Some(missing) = missing {
|
||||
why += &format!("; not X11 because its libraries are missing: {missing}");
|
||||
}
|
||||
(x11, why)
|
||||
}
|
||||
|
||||
/// `Err` naming the libraries winit's X11 backend opens at startup that can't be loaded: libX11,
|
||||
/// libXcursor, libX11-xcb (which brings in libxcb), libXi, and libxkbcommon-x11 for the keyboard.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn x11_libraries() -> Result<(), String> {
|
||||
use x11_dl::{xcursor::Xcursor, xinput2::XInput2, xlib::Xlib, xlib_xcb::Xlib_xcb};
|
||||
let mut missing: Vec<String> = [Xlib::open().err(), Xcursor::open().err(), Xlib_xcb::open().err(), XInput2::open().err()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|e| e.to_string())
|
||||
.collect();
|
||||
if xkbcommon_dl::x11::xkbcommon_x11_option().is_none() {
|
||||
missing.push("libxkbcommon-x11.so.0 could not be loaded".into());
|
||||
}
|
||||
if missing.is_empty() { Ok(()) } else { Err(missing.join("; ")) }
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
if let Err(e) = try_save_prefs(app) {
|
||||
log::warn!("preferences: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
|
||||
/// because preferences are switched off or there is no settings folder.
|
||||
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(dir)
|
||||
{
|
||||
log::warn!("preferences: cannot create {}: {e}", dir.display());
|
||||
return;
|
||||
}
|
||||
match serde_json::to_vec_pretty(&app.prefs()) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = write_prefs(&p, &bytes) {
|
||||
log::warn!("preferences: cannot save {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
|
||||
}
|
||||
let Some(p) = prefs_path() else { return Ok(false) };
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
|
||||
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
|
||||
@@ -348,6 +383,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
save_prefs: Some(Box::new(try_save_prefs)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -421,14 +457,15 @@ fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word
|
||||
/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = wordcraft_engine::io::read_document(path)
|
||||
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
.and_then(|bytes| app.open_from_platform(&name, Some(&wordcraft_engine::cmd::insert::base64_encode(&bytes))));
|
||||
match opened {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
@@ -449,9 +486,22 @@ fn main() -> eframe::Result {
|
||||
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// Linux: before anything reads another program's AppImage variables, or a thread starts (#5653).
|
||||
#[cfg(target_os = "linux")]
|
||||
let start_over_failed = appimage_env::drop_inherited();
|
||||
// Still carrying them, this process must never update itself: the update would replace the
|
||||
// other program's file.
|
||||
#[cfg(target_os = "linux")]
|
||||
let updates_unsafe = start_over_failed.is_some();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let updates_unsafe = false;
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
logging::install_panic_hook();
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(e) = start_over_failed {
|
||||
log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}");
|
||||
}
|
||||
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
@@ -537,7 +587,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
|
||||
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name), x11_libraries);
|
||||
log::info!("{why}");
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
@@ -566,6 +616,10 @@ fn main() -> eframe::Result {
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
// After the preferences, so the reason it gives is in the interface language.
|
||||
if updates_unsafe {
|
||||
app.disable_self_update();
|
||||
}
|
||||
load_user_dictionary(&mut app);
|
||||
// Language packs from the suite's font store; an Install click is the person's own.
|
||||
wordcraft_engine::packs::use_font_store(true);
|
||||
@@ -605,8 +659,9 @@ fn main() -> eframe::Result {
|
||||
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
|
||||
app.start_screen();
|
||||
}
|
||||
// A Word template named here starts a new document based on it (#3433).
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
if let Err(e) = app.open_from_platform(&f, None) {
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
@@ -664,7 +719,7 @@ mod tests {
|
||||
fn the_display_server_follows_the_variable_then_the_setting() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let x11 = |setting, vars: &[(&str, &str)]| {
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
|
||||
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v)), || Ok(())).0
|
||||
};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
|
||||
@@ -680,10 +735,40 @@ mod tests {
|
||||
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
|
||||
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
|
||||
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
|
||||
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()), || Ok(()));
|
||||
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
|
||||
}
|
||||
|
||||
/// Without the X11 libraries (the packages only recommend them) X11 isn't forced, whatever asks
|
||||
/// for it: forced X11 failed to start, and winit panicked without libxkbcommon-x11. The check
|
||||
/// runs only when X11 would be forced.
|
||||
#[test]
|
||||
fn missing_x11_libraries_leave_the_app_on_wayland() {
|
||||
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
|
||||
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
|
||||
for (setting, variable) in [(Auto, None), (X11, None), (Wayland, Some("0"))] {
|
||||
let env = |name: &str| {
|
||||
both.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| (*v).into())
|
||||
.or_else(|| (name == "WORDCRAFT_WAYLAND").then_some(variable).flatten().map(Into::into))
|
||||
};
|
||||
let (x11, why) = uses_x11(setting, env, || Err("libxkbcommon-x11.so.0 could not be loaded".into()));
|
||||
assert!(!x11, "{setting:?} with WORDCRAFT_WAYLAND={variable:?}: {why}");
|
||||
assert!(why.contains("native Wayland") && why.contains("libxkbcommon-x11"), "{why}");
|
||||
}
|
||||
let checked = std::cell::Cell::new(false);
|
||||
let (x11, _) = uses_x11(
|
||||
Wayland,
|
||||
|name| both.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).into()),
|
||||
|| {
|
||||
checked.set(true);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
assert!(!x11 && !checked.get(), "asked for Wayland, the libraries aren't checked");
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update
|
||||
//! would take for this copy's file and replace (#5653). The real program, started both ways.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const NOTICE: &str = "ignoring the AppImage";
|
||||
|
||||
/// A folder of its own for one test, removed when it ends.
|
||||
struct Tmp(PathBuf);
|
||||
|
||||
impl Tmp {
|
||||
/// An empty folder named after `test` and this process, made afresh.
|
||||
fn new(test: &str) -> Tmp {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Tmp(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tmp {
|
||||
/// Removes the folder and what the test left in it.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would
|
||||
/// start it, and waits up to 30 s for it to exit (with no display it can't open a window).
|
||||
fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
.env("APPIMAGE", appimage)
|
||||
.env("APPDIR", appdir)
|
||||
.env("ARGV0", "other-app")
|
||||
.env("OWD", "/")
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("WORDCRAFT_CONTROL_PORT")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
// A start-over that kept the variables would start over for ever, and end here.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {args:?} was still running after 30 s");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// The notice comes once: the program it started over as no longer sees the variables.
|
||||
#[test]
|
||||
fn another_programs_variables_are_dropped_once_and_the_program_carries_on() {
|
||||
let tmp = Tmp::new("dropped");
|
||||
let other = tmp.0.join("other-app.AppImage");
|
||||
std::fs::write(&other, b"another program").unwrap();
|
||||
let out = app(&["--version".into()], &other, &tmp.0);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
|
||||
assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}");
|
||||
assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}");
|
||||
assert_eq!(std::fs::read(&other).unwrap(), b"another program");
|
||||
}
|
||||
|
||||
/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode.
|
||||
#[test]
|
||||
fn starting_over_keeps_the_arguments() {
|
||||
let tmp = Tmp::new("args");
|
||||
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
/// Its own image's variables stay, or the self-update couldn't find the file to replace.
|
||||
#[test]
|
||||
fn its_own_images_variables_stay() {
|
||||
let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap();
|
||||
let mount = exe.parent().unwrap();
|
||||
let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
|
||||
assert!(!stderr.contains(NOTICE), "stderr: {stderr}");
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod appimage_env;
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.SenuraMalaka]
|
||||
real_name = "Senura Dissanayake"
|
||||
sync_github_name = true
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
@@ -197,3 +201,9 @@ display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
[people.benandez]
|
||||
sync_github_name = true
|
||||
|
||||
+19
-10
@@ -7,8 +7,9 @@
|
||||
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
|
||||
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
|
||||
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
|
||||
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
|
||||
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
|
||||
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
|
||||
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
|
||||
/// listed name) stays within about the file's own size. Anything else that is wrong is left for
|
||||
/// `cfb` to report.
|
||||
pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
@@ -52,12 +53,14 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
while difat.last() == Some(&FREE) {
|
||||
difat.pop();
|
||||
}
|
||||
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
|
||||
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
|
||||
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
|
||||
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
|
||||
// sectors than the file has — every FAT sector is a sector of the file, and none of them
|
||||
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
|
||||
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
|
||||
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
|
||||
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
|
||||
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
|
||||
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
|
||||
if listed.len() > needed {
|
||||
if listed.len() > num_sectors {
|
||||
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::with_capacity(listed.len());
|
||||
@@ -123,10 +126,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
|
||||
// Each list is shorter than the file has sectors, which the guard used to accept.
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
|
||||
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
|
||||
// sector of the file, so a longer list is one of the two.
|
||||
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
|
||||
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
|
||||
assert!(
|
||||
super::check(&header_difat(10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
|
||||
);
|
||||
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
|
||||
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
|
||||
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
|
||||
assert!(super::check(&header_difat(10, &[0])).is_ok());
|
||||
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ impl Document {
|
||||
glossary: Vec::new(),
|
||||
media_index: MediaIndex::default(),
|
||||
kept: Default::default(),
|
||||
missing_pictures: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +155,7 @@ impl Document {
|
||||
glossary,
|
||||
media_index: _,
|
||||
kept: _,
|
||||
missing_pictures: _,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
@@ -174,6 +176,7 @@ impl Document {
|
||||
glossary: other_glossary,
|
||||
media_index: _,
|
||||
kept: _,
|
||||
missing_pictures: _,
|
||||
} = other;
|
||||
same_blocks(body, other_body)
|
||||
&& same_parts(parts, other_parts)
|
||||
|
||||
@@ -772,6 +772,10 @@ impl Document {
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Removing an already-empty paragraph must not impose its style or mark formatting
|
||||
// on the following content. Test before trimming: deleting actual heading text is
|
||||
// still an ordinary merge that keeps the first paragraph's properties.
|
||||
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
@@ -819,15 +823,18 @@ impl Document {
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.clone());
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
if let Some(mark) = keep_mark {
|
||||
pa.mark = mark;
|
||||
}
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
|
||||
@@ -478,8 +478,9 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
|
||||
/// differ, the page colour and picture, the watermark (one with no text isn't written), 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);
|
||||
@@ -490,8 +491,10 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
|
||||
match serde_json::to_vec(&(
|
||||
sections,
|
||||
parts,
|
||||
s.even_odd_headers,
|
||||
&s.page_color,
|
||||
&s.page_picture,
|
||||
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
|
||||
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
|
||||
(&doc.footnote_separators, &doc.endnote_separators),
|
||||
)) {
|
||||
|
||||
@@ -75,6 +75,7 @@ mod block;
|
||||
mod compare;
|
||||
mod document;
|
||||
mod error;
|
||||
mod missing;
|
||||
mod parts;
|
||||
mod position;
|
||||
mod settings;
|
||||
@@ -84,6 +85,7 @@ pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH};
|
||||
pub use compare::para_block;
|
||||
use document::MediaIndex;
|
||||
pub use error::{DocError, Result};
|
||||
pub use missing::{MAX_MISSING_PICTURES, MissingPicture, MissingPictureReason, MissingPictures, picture_hash};
|
||||
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
|
||||
pub use position::{Path, Pos, StoryRef};
|
||||
pub use settings::{
|
||||
@@ -138,6 +140,10 @@ pub struct Document {
|
||||
/// [`kept`]); never saved, ignored by `==`.
|
||||
#[serde(skip)]
|
||||
pub kept: kept::Kept,
|
||||
/// Pictures a reader dropped because their target is missing, external or link-only (strict
|
||||
/// export reports them); never saved, never drawn, ignored by `==`.
|
||||
#[serde(skip)]
|
||||
pub missing_pictures: MissingPictures,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
// Exporters drop other control characters and noncharacters when they write the link
|
||||
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
|
||||
// stands and written as something else.
|
||||
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
|
||||
return false;
|
||||
}
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
@@ -125,6 +131,7 @@ mod tests {
|
||||
("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 "x \* Upper""#, Some("x * Upper")),
|
||||
(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")),
|
||||
@@ -199,6 +206,10 @@ mod tests {
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
// Characters the exporters drop on writing don't hide a scheme.
|
||||
"file\u{85}:///etc/passwd",
|
||||
"java\u{9F}script:alert(1)",
|
||||
"/\u{FFFE}/attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Pictures a reader could not bring in or an exporter could not draw, for strict export (#6005).
|
||||
//! Kept beside the document while it is open; never saved, never drawn, and not part of the
|
||||
//! document's content (`==` ignores it).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most pictures remembered per document; past it [`MissingPictures::truncated`] is set.
|
||||
pub const MAX_MISSING_PICTURES: usize = 256;
|
||||
|
||||
/// Most characters of a picture's part or story name a record keeps (names come from the file).
|
||||
pub const MAX_PICTURE_NAME_CHARS: usize = 1024;
|
||||
|
||||
/// Why a picture is not in the document or not drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum MissingPictureReason {
|
||||
/// The relationship names a part the file does not contain, or no relationship at all.
|
||||
MissingPart,
|
||||
/// The target is outside the file (`TargetMode="External"`); it is never fetched.
|
||||
ExternalTarget,
|
||||
/// The picture only links to a file (`r:link`) and holds no embedded copy.
|
||||
LinkOnly,
|
||||
/// The bytes are a picture format we know but they do not decode.
|
||||
Undecodable,
|
||||
/// The bytes are not a picture format we can draw.
|
||||
UnsupportedFormat,
|
||||
}
|
||||
|
||||
impl MissingPictureReason {
|
||||
/// The words used in reports (`"missing part"`, `"external target"`, `"link only"`,
|
||||
/// `"undecodable"`, `"unsupported format"`).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingPart => "missing part",
|
||||
Self::ExternalTarget => "external target",
|
||||
Self::LinkOnly => "link only",
|
||||
Self::Undecodable => "undecodable",
|
||||
Self::UnsupportedFormat => "unsupported format",
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason a [`code`](Self::code) names.
|
||||
pub fn from_code(code: &str) -> Option<Self> {
|
||||
[Self::MissingPart, Self::ExternalTarget, Self::LinkOnly, Self::Undecodable, Self::UnsupportedFormat].into_iter().find(|r| r.code() == code)
|
||||
}
|
||||
|
||||
/// The stable snake_case code the JSON report carries (`"missing_part"`, `"external_target"`,
|
||||
/// `"link_only"`, `"undecodable"`, `"unsupported_format"`); never reworded.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingPart => "missing_part",
|
||||
Self::ExternalTarget => "external_target",
|
||||
Self::LinkOnly => "link_only",
|
||||
Self::Undecodable => "undecodable",
|
||||
Self::UnsupportedFormat => "unsupported_format",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One picture that is missing.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct MissingPicture {
|
||||
/// The target as written, or the part name it resolved to (a media key for a decoded one).
|
||||
pub part: String,
|
||||
/// The story part it is in (`word/document.xml`, `word/header2.xml`), as the source file
|
||||
/// names it; empty when not known.
|
||||
pub story: String,
|
||||
pub reason: MissingPictureReason,
|
||||
}
|
||||
|
||||
/// A capped list of [`MissingPicture`]s, with the source names a report needs to say where a
|
||||
/// picture is (a reader fills them; a document made or opened another way leaves them empty).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MissingPictures {
|
||||
pub list: Vec<MissingPicture>,
|
||||
/// More than [`MAX_MISSING_PICTURES`] were found; the rest are not listed.
|
||||
pub truncated: bool,
|
||||
/// Pictures found, those past the cap included (saturating). Each distinct one counts once.
|
||||
pub total: u64,
|
||||
/// The main story's part name in the source file (`word/document.xml`).
|
||||
pub main: String,
|
||||
/// A header, footer, note or comment story's part id ([`crate::Document::parts`]) to the
|
||||
/// part it was read from (`word/header2.xml`). Ids freed and reused after opening may name
|
||||
/// the old part.
|
||||
pub part_names: std::collections::BTreeMap<u32, std::sync::Arc<str>>,
|
||||
/// A [`crate::Document::media`] key to the part its bytes were read from
|
||||
/// (`word/media/image2.bin`).
|
||||
pub media_parts: std::collections::BTreeMap<String, String>,
|
||||
/// What `list` holds, for an O(1) test of "already there" (a hostile file has many).
|
||||
seen: std::collections::HashSet<MissingPicture>,
|
||||
/// Hashes of the distinct pictures past the cap (8 bytes each), so the total counts them once.
|
||||
unlisted: std::collections::HashSet<u64>,
|
||||
/// The chunk parts whose unlisted pictures [`Self::add_unlisted`] has counted, so a chunk
|
||||
/// imported twice counts them once.
|
||||
counted_chunks: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
impl MissingPictures {
|
||||
/// Add `n` pictures of the chunk part `chunk` that its reader counted but did not list. The
|
||||
/// same chunk part counts once however often it is imported (its listed pictures dedupe
|
||||
/// through [`Self::push`]).
|
||||
pub fn add_unlisted(&mut self, chunk: &str, n: u64) {
|
||||
if n > 0 && self.counted_chunks.insert(chunk.to_string()) {
|
||||
self.total = self.total.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add one (the same part, story and reason once).
|
||||
pub fn push(&mut self, mut item: MissingPicture) {
|
||||
for name in [&mut item.part, &mut item.story] {
|
||||
if name.len() > MAX_PICTURE_NAME_CHARS {
|
||||
*name = name.chars().take(MAX_PICTURE_NAME_CHARS).collect();
|
||||
}
|
||||
}
|
||||
if self.seen.contains(&item) {
|
||||
return;
|
||||
}
|
||||
if self.list.len() >= MAX_MISSING_PICTURES {
|
||||
self.truncated = true;
|
||||
if self.unlisted.insert(picture_hash(&item)) {
|
||||
self.total = self.total.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.total = self.total.saturating_add(1);
|
||||
self.seen.insert(item.clone());
|
||||
self.list.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64-bit hash of a record, to tell the ones past a cap apart without keeping them.
|
||||
pub fn picture_hash(item: &MissingPicture) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
item.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
impl PartialEq for MissingPictures {
|
||||
/// Not part of the document's content.
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ impl Numbering {
|
||||
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
|
||||
/// one), without counting it.
|
||||
pub fn shows_label(&self, num: u32, level: u8) -> bool {
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
|
||||
}
|
||||
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
|
||||
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
|
||||
@@ -325,12 +325,9 @@ impl Counters {
|
||||
let lv = (level as usize).min(8);
|
||||
// This list's own definition of a level wins over its abstract list's.
|
||||
let level_def = |k: usize| numbering.level(num, k as u8);
|
||||
// A "none" level (Word's numFmt none) still counts and restarts the levels below it; only
|
||||
// its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985).
|
||||
let def = level_def(lv)?.clone();
|
||||
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
|
||||
if def.format == NumFormat::None {
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
let fl = self.flags.entry(abs.id).or_default();
|
||||
// Only this level's override, not the num's others: the counters are shared by every
|
||||
@@ -382,7 +379,14 @@ impl Counters {
|
||||
let started = *fl;
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
// A "none" level shows its literal text, but has no number of its own for a reference to
|
||||
// show (#4985): it is kept as one that shows none.
|
||||
let counted = if def.format == NumFormat::None {
|
||||
CountedLabel { label: None, levels: Vec::new() }
|
||||
} else {
|
||||
CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }
|
||||
};
|
||||
self.labels.insert((abs.id, lv as u8), counted);
|
||||
Some((label, def))
|
||||
}
|
||||
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
|
||||
@@ -455,9 +459,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
NumFormat::DecimalZero => NumFormat::DecimalZero,
|
||||
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
|
||||
_ if def.legal => NumFormat::Decimal,
|
||||
f => f,
|
||||
};
|
||||
@@ -556,6 +560,27 @@ mod tests {
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_none_level_shows_its_text_and_still_counts() {
|
||||
// As Word shows them (#4985): a "none" level's literal text is its label, its own number
|
||||
// reads as nothing (also in legal numbering), and it restarts the levels below it.
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() };
|
||||
let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)];
|
||||
}
|
||||
assert!(n.shows_label(id, 0));
|
||||
let mut c = Counters::default();
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
|
||||
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
|
||||
|
||||
@@ -186,6 +186,16 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`,
|
||||
/// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a
|
||||
/// drawing on its anchor paragraph's page.
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub page: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub bare_turn: bool,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -241,6 +251,7 @@ impl Float {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
self.bare_turn = false;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
@@ -250,6 +261,23 @@ impl Float {
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
|
||||
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
|
||||
pub fn is_bare_turn(&self) -> bool {
|
||||
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
|
||||
}
|
||||
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
|
||||
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
|
||||
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
|
||||
/// and from 225° up to 315°.
|
||||
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
|
||||
if !self.is_bare_turn() {
|
||||
return self.spin_pad(w, h);
|
||||
}
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let deg = wordcraft_geom::normalize_degrees(self.rot);
|
||||
if modern && ((45.0..135.0).contains(°) || (225.0..315.0).contains(°)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
|
||||
}
|
||||
/// The effect extents, finite and clamped (left, top, right, bottom).
|
||||
pub fn effect_extent(&self) -> [f32; 4] {
|
||||
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
|
||||
|
||||
@@ -27,7 +27,8 @@ pub use char_props::{
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
|
||||
TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
|
||||
@@ -26,6 +26,67 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
|
||||
///
|
||||
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
|
||||
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParagraphTextDirection {
|
||||
LrTb,
|
||||
LrTbV,
|
||||
Tb,
|
||||
TbRl,
|
||||
TbRlV,
|
||||
TbLr,
|
||||
TbLrV,
|
||||
BtLr,
|
||||
TbV,
|
||||
Rl,
|
||||
RlV,
|
||||
Lr,
|
||||
LrV,
|
||||
}
|
||||
|
||||
impl ParagraphTextDirection {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"lrTb" => Some(Self::LrTb),
|
||||
"lrTbV" => Some(Self::LrTbV),
|
||||
"tb" => Some(Self::Tb),
|
||||
"tbRl" => Some(Self::TbRl),
|
||||
"tbRlV" => Some(Self::TbRlV),
|
||||
"tbLr" => Some(Self::TbLr),
|
||||
"tbLrV" => Some(Self::TbLrV),
|
||||
"btLr" => Some(Self::BtLr),
|
||||
"tbV" => Some(Self::TbV),
|
||||
"rl" => Some(Self::Rl),
|
||||
"rlV" => Some(Self::RlV),
|
||||
"lr" => Some(Self::Lr),
|
||||
"lrV" => Some(Self::LrV),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::LrTb => "lrTb",
|
||||
Self::LrTbV => "lrTbV",
|
||||
Self::Tb => "tb",
|
||||
Self::TbRl => "tbRl",
|
||||
Self::TbRlV => "tbRlV",
|
||||
Self::TbLr => "tbLr",
|
||||
Self::TbLrV => "tbLrV",
|
||||
Self::BtLr => "btLr",
|
||||
Self::TbV => "tbV",
|
||||
Self::Rl => "rl",
|
||||
Self::RlV => "rlV",
|
||||
Self::Lr => "lr",
|
||||
Self::LrV => "lrV",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -391,6 +452,9 @@ pub struct ParaProps {
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_direction: Option<ParagraphTextDirection>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
@@ -462,6 +526,7 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
|
||||
@@ -137,9 +137,12 @@ impl ResolvedChar {
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
}
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
|
||||
/// 0.65 of the run's size rounded to the half point, a font size the format can store
|
||||
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
|
||||
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -453,6 +456,32 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
|
||||
/// its PDF export): 0.65 of the size, rounded to the half point.
|
||||
#[test]
|
||||
fn raised_and_lowered_text_is_sized_to_the_half_point() {
|
||||
for (size, want) in [
|
||||
(8.0, 5.0),
|
||||
(9.0, 6.0),
|
||||
(10.0, 6.5),
|
||||
(11.0, 7.0),
|
||||
(12.0, 8.0),
|
||||
(13.0, 8.5),
|
||||
(14.0, 9.0),
|
||||
(16.0, 10.5),
|
||||
(20.0, 13.0),
|
||||
(24.0, 15.5),
|
||||
(36.0, 23.5),
|
||||
] {
|
||||
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
|
||||
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
|
||||
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
|
||||
}
|
||||
}
|
||||
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
|
||||
}
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
|
||||
@@ -289,13 +289,14 @@ impl NumFormat {
|
||||
pub fn from_ooxml(s: &str) -> NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
|
||||
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
use crate::cjk_numbers as cjk;
|
||||
match self {
|
||||
NumFormat::DecimalZero => format!("{n:02}"),
|
||||
NumFormat::ThaiNumbers => thai_digits(n),
|
||||
NumFormat::UpperRoman => roman(n).to_uppercase(),
|
||||
NumFormat::LowerRoman => roman(n),
|
||||
NumFormat::UpperLetter => letters(n).to_uppercase(),
|
||||
@@ -349,7 +350,6 @@ impl NumFormat {
|
||||
| NumFormat::HindiNumbers
|
||||
| NumFormat::HindiCounting
|
||||
| NumFormat::ThaiLetters
|
||||
| NumFormat::ThaiNumbers
|
||||
| NumFormat::ThaiCounting
|
||||
| NumFormat::BahtText
|
||||
| NumFormat::DollarText => n.to_string(),
|
||||
@@ -357,6 +357,11 @@ impl NumFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
|
||||
fn thai_digits(n: u32) -> String {
|
||||
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
|
||||
}
|
||||
|
||||
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
|
||||
fn chicago(n: u32) -> String {
|
||||
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
|
||||
@@ -999,6 +1004,18 @@ mod tests {
|
||||
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
|
||||
}
|
||||
|
||||
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
|
||||
#[test]
|
||||
fn thai_numbers_use_thai_digits() {
|
||||
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
|
||||
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
|
||||
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
|
||||
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
|
||||
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
|
||||
// Formats not drawn yet still show decimal.
|
||||
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_letters_follow_word() {
|
||||
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
@@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
|
||||
@@ -786,3 +786,31 @@ fn lone_table_open_markers_mixed_tables_and_paired() {
|
||||
assert!(res1.controls.open.is_empty());
|
||||
assert_eq!(res1.controls.close, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_picture_names_are_capped_at_a_char_boundary() {
|
||||
let mut d = Document::new();
|
||||
let long: String = "é".repeat(5000);
|
||||
d.missing_pictures.push(MissingPicture { part: long.clone(), story: long, reason: MissingPictureReason::MissingPart });
|
||||
let item = &d.missing_pictures.list[0];
|
||||
assert_eq!((item.part.chars().count(), item.story.chars().count()), (1024, 1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pictures_are_capped_deduplicated_and_not_content() {
|
||||
let mut d = Document::new();
|
||||
let item = |n: usize| MissingPicture { part: format!("p{n}"), story: "word/document.xml".into(), reason: MissingPictureReason::MissingPart };
|
||||
d.missing_pictures.push(item(0));
|
||||
d.missing_pictures.push(item(0));
|
||||
assert_eq!(d.missing_pictures.list.len(), 1);
|
||||
for n in 1..MAX_MISSING_PICTURES + 3 {
|
||||
d.missing_pictures.push(item(n));
|
||||
}
|
||||
assert_eq!(d.missing_pictures.list.len(), MAX_MISSING_PICTURES);
|
||||
assert!(d.missing_pictures.truncated);
|
||||
let e = Document::new();
|
||||
assert_eq!(d, e);
|
||||
assert!(d.same_content(&e));
|
||||
assert!(!serde_json::to_string(&d).unwrap().contains("p0"));
|
||||
assert_eq!(MissingPictureReason::ExternalTarget.label(), "external target");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
|
||||
fn fixture(first: &str, style: Option<&str>) -> Document {
|
||||
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
p.props.outline_level = Some(0);
|
||||
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.props = ParaProps {
|
||||
style: style.map(str::to_string),
|
||||
align: Some(Align::Right),
|
||||
indent_left: Some(18.0),
|
||||
space_after: Some(7.0),
|
||||
keep_next: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
|
||||
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Compare paragraph, mark and inline-run formatting independently of text.
|
||||
fn assert_format(p: &Paragraph, expected: &Paragraph) {
|
||||
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
|
||||
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
}
|
||||
|
||||
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
|
||||
#[test]
|
||||
fn deleting_an_empty_paragraph_keeps_the_following_format() {
|
||||
for first_style in ["Heading1", "Heading3", "Normal"] {
|
||||
for style in [None, Some("Normal"), Some("Heading2")] {
|
||||
let mut d = fixture("", style);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text);
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
|
||||
#[test]
|
||||
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
|
||||
for backwards in [false, true] {
|
||||
let mut d = fixture("", None);
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
|
||||
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
|
||||
expected.delete(0, 5).unwrap();
|
||||
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
|
||||
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "paragraph.");
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A control whose closing marker survives must move across the join without replacing tail formatting.
|
||||
#[test]
|
||||
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
|
||||
use wordcraft_doc::control::ContentControl;
|
||||
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
|
||||
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
|
||||
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.mark.ins = Some(0);
|
||||
p.mark.del = Some(1);
|
||||
let expected = p.clone();
|
||||
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
|
||||
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
|
||||
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
|
||||
assert_eq!(p.props_of_char(0), &marker);
|
||||
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
|
||||
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, Pos::body(0, 0));
|
||||
assert_eq!(ranges[0].end, Pos::body(1, 5));
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
}
|
||||
|
||||
/// The empty-paragraph rule applies in header stories and table-cell containers.
|
||||
#[test]
|
||||
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
|
||||
for cell in [false, true] {
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let (story, a, b) = if cell {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = d.body.clone();
|
||||
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
|
||||
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
|
||||
} else {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
|
||||
(StoryRef::Part(id), Path::top(0), Path::top(1))
|
||||
};
|
||||
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
|
||||
assert_format(d.para(story, &a).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbing an empty section keeps the following format and inherited header.
|
||||
#[test]
|
||||
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
|
||||
let mut d = fixture("", None);
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
|
||||
let mut section = d.last_section.clone();
|
||||
section.headers.default = Some(header);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
|
||||
d.last_section.margin_left = 96.0;
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
|
||||
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
|
||||
assert_eq!(d.last_section.headers.default, Some(header));
|
||||
assert_eq!(d.last_section.margin_left, 96.0);
|
||||
}
|
||||
|
||||
/// Deleting actual text continues to use the first paragraph merge rule.
|
||||
#[test]
|
||||
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
|
||||
for off in [0, 7] {
|
||||
let mut d = fixture("Heading", None);
|
||||
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||||
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing a soft line break must not change paragraph formatting.
|
||||
#[test]
|
||||
fn a_soft_break_is_not_an_empty_paragraph_merge() {
|
||||
let mut d = fixture("", None);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
|
||||
let expected = p.props.clone();
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.body.len(), 2);
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
@@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
|
||||
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
|
||||
// drawn as their box here, which a default fill would make an opaque block over the text.
|
||||
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
|
||||
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
|
||||
+52
-13
@@ -7,9 +7,26 @@ use crate::DocbinError;
|
||||
/// Largest bin table (entries) we accept.
|
||||
const MAX_BINS: usize = 100_000;
|
||||
|
||||
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ChpxRun<'w> {
|
||||
pub(crate) grpprl: &'w [u8],
|
||||
lo: u32,
|
||||
hi: u32,
|
||||
}
|
||||
|
||||
impl ChpxRun<'_> {
|
||||
pub(crate) fn contains(&self, fc: u32) -> bool {
|
||||
self.lo <= fc && fc < self.hi
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
|
||||
pub(crate) struct Bins {
|
||||
fcs: Vec<u32>,
|
||||
/// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt
|
||||
/// table that does not gets no cached ranges, [`ChpxRun`]).
|
||||
sorted: bool,
|
||||
/// Page number (× 512 = byte offset in the WordDocument stream).
|
||||
pns: Vec<u32>,
|
||||
}
|
||||
@@ -20,7 +37,7 @@ impl Bins {
|
||||
let fc = fc as usize;
|
||||
let lcb = lcb as usize;
|
||||
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
|
||||
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
|
||||
return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() });
|
||||
}
|
||||
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
|
||||
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
|
||||
@@ -38,36 +55,58 @@ impl Bins {
|
||||
// PnBte* keeps the page number in its low bits; the rest must be zero.
|
||||
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
|
||||
}
|
||||
Ok(Bins { fcs, pns })
|
||||
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
|
||||
Ok(Bins { fcs, sorted, pns })
|
||||
}
|
||||
|
||||
/// The page containing `fc`, as a slice of the WordDocument stream.
|
||||
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
|
||||
/// The page containing `fc`, as a slice of the WordDocument stream, and the FC range
|
||||
/// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table
|
||||
/// is not ascending, so nothing is cached from it).
|
||||
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> {
|
||||
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
|
||||
let pn = *self.pns.get(i)?;
|
||||
let at = pn.checked_mul(512)? as usize;
|
||||
word.get(at..at.checked_add(512)?)
|
||||
let page = word.get(at..at.checked_add(512)?)?;
|
||||
if !self.sorted {
|
||||
return Some((page, 1, 0));
|
||||
}
|
||||
let lo = self.fcs.get(i).copied().unwrap_or(0);
|
||||
let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX);
|
||||
Some((page, lo, hi))
|
||||
}
|
||||
|
||||
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
|
||||
/// crun in the last byte).
|
||||
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
|
||||
let Some(page) = self.page(word, fc) else { return &[] };
|
||||
let Some(&crun) = page.get(511) else { return &[] };
|
||||
self.chpx_run(word, fc).grpprl
|
||||
}
|
||||
|
||||
/// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk
|
||||
/// over consecutive characters looks it up once per run, not once per character.
|
||||
pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> {
|
||||
let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 };
|
||||
let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none };
|
||||
let Some(&crun) = page.get(511) else { return none };
|
||||
let crun = crun.min(0x65) as usize;
|
||||
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
|
||||
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
|
||||
// region between rgb's end and crun.
|
||||
let rgb_at = (crun + 1) * 4;
|
||||
// The last run starting at or before `fc`. It stays the answer for every FC from its own
|
||||
// start up to the smallest start after it (also in a page whose starts do not ascend).
|
||||
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
|
||||
let Some(j) = j else { return &[] };
|
||||
let Some(&off) = page.get(rgb_at + j) else { return &[] };
|
||||
let first_after = j.map_or(0, |j| j + 1);
|
||||
let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi));
|
||||
let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo));
|
||||
let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi };
|
||||
let Some(j) = j else { return run(&[]) };
|
||||
let Some(&off) = page.get(rgb_at + j) else { return run(&[]) };
|
||||
if off == 0 {
|
||||
return &[];
|
||||
return run(&[]);
|
||||
}
|
||||
let at = off as usize * 2;
|
||||
let Some(&cb) = page.get(at) else { return &[] };
|
||||
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
|
||||
let Some(&cb) = page.get(at) else { return run(&[]) };
|
||||
run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]))
|
||||
}
|
||||
|
||||
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
|
||||
@@ -75,7 +114,7 @@ impl Bins {
|
||||
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
|
||||
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
|
||||
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
|
||||
let Some(page) = self.page(word, fc) else { return (0, &[]) };
|
||||
let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) };
|
||||
let Some(&crun) = page.get(511) else { return (0, &[]) };
|
||||
// BX is 13 bytes; cap crun at what fits before byte 511.
|
||||
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
|
||||
|
||||
+115
-43
@@ -260,6 +260,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
};
|
||||
|
||||
let mut doc = Document::new();
|
||||
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
|
||||
// 15 would apply Word 2013+ justification and line-breaking rules to them.
|
||||
doc.settings.compat_mode = 11;
|
||||
if let Some(core) = metadata::read(&mut comp) {
|
||||
doc.core = core;
|
||||
}
|
||||
@@ -345,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -433,6 +436,36 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a
|
||||
/// non-empty header story ends with a paragraph mark that only separates it from the next story
|
||||
/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the
|
||||
/// guard would add an empty paragraph to every header and footer and push the body down when
|
||||
/// the header reaches past the top margin (#5922).
|
||||
///
|
||||
/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a
|
||||
/// mark stays a part (an empty header, not one linked to the previous section), and a mark right
|
||||
/// after a table row stays the paragraph that closes the table.
|
||||
fn header_story_blocks(
|
||||
ctx: &WalkCtx<'_>,
|
||||
a: u32,
|
||||
b: u32,
|
||||
pics: &mut media::Pictures,
|
||||
max_paragraphs: usize,
|
||||
) -> Result<wordcraft_doc::Blocks, DocbinError> {
|
||||
let mut paras = walk(ctx, a, b, pics);
|
||||
if ctx.paragraphs.exceeded.get() {
|
||||
return Err(paragraph_limit(max_paragraphs));
|
||||
}
|
||||
let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark);
|
||||
if let [.., prev, last] = paras.as_slice() {
|
||||
let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none();
|
||||
if guard && outside_table(prev) {
|
||||
paras.pop();
|
||||
}
|
||||
}
|
||||
Ok(table::assemble(paras))
|
||||
}
|
||||
|
||||
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
|
||||
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
|
||||
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
|
||||
@@ -502,29 +535,37 @@ struct ParaBuild {
|
||||
runs: Vec<Run>,
|
||||
props: wordcraft_doc::props::ParaProps,
|
||||
mark: CharProps,
|
||||
/// The current run's properties, so runs only break when formatting changes.
|
||||
cur: Option<CharProps>,
|
||||
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
|
||||
/// the last run is still the one it extended: the same generation means equal properties.
|
||||
run_gen: Option<u64>,
|
||||
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
|
||||
objects: Vec<InlineObject>,
|
||||
}
|
||||
|
||||
impl ParaBuild {
|
||||
fn push(&mut self, c: char, props: CharProps) {
|
||||
self.run_gen = None;
|
||||
let len = c.len_utf8();
|
||||
match &self.cur {
|
||||
Some(p) if *p == props => {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
r.len += len;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.runs.push(Run { len, props: props.clone() });
|
||||
self.cur = Some(props);
|
||||
}
|
||||
match self.runs.last_mut() {
|
||||
Some(r) if r.props == props => r.len += len,
|
||||
_ => self.runs.push(Run { len, props }),
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// [`ParaBuild::push`] for properties that are resolved once and shared by many characters:
|
||||
/// they are cloned only when a run starts, and not even compared while `generation` stays
|
||||
/// the same.
|
||||
fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) {
|
||||
let len = c.len_utf8();
|
||||
match self.runs.last_mut() {
|
||||
Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len,
|
||||
_ => self.runs.push(Run { len, props: props.clone() }),
|
||||
}
|
||||
self.run_gen = Some(generation);
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// Append `c` to the current run, whose properties the caller knows it matches.
|
||||
fn extend(&mut self, c: char) {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
@@ -552,7 +593,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
// Field assembly state: instruction characters are swallowed, the result is kept until
|
||||
// the end character, then emitted as one object (hyperlinks keep their result text).
|
||||
let mut field: Option<FieldBuild> = None;
|
||||
let mut memo: Option<FmtMemo<'a>> = None;
|
||||
let mut fmt = FmtState::default();
|
||||
while cp < cp_end {
|
||||
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
|
||||
// an empty table and `cp` never matches the main-document CPs).
|
||||
@@ -715,16 +756,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
|
||||
}
|
||||
}
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt),
|
||||
// Object anchors, comment references and other special characters are dropped
|
||||
// (or handled by later passes).
|
||||
c if (c as u32) < 0x20 => {}
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
@@ -854,34 +895,53 @@ fn num_of(ilfo: i32) -> Option<u32> {
|
||||
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
|
||||
/// the character is recorded into the instruction or the cached result instead of (or as
|
||||
/// well as) the visible text.
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
|
||||
let prm = ctx.pieces.prm_of(cp);
|
||||
let style = pb.props.style.as_deref();
|
||||
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
|
||||
Some(m) => m.props.clone(),
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) {
|
||||
// The piece and the CHPX run of the previous character usually cover this one too.
|
||||
let span = match st.span.filter(|s| s.contains(cp)) {
|
||||
Some(s) => Some(s),
|
||||
None => {
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
|
||||
props
|
||||
st.span = ctx.pieces.span(cp);
|
||||
st.span
|
||||
}
|
||||
};
|
||||
let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0);
|
||||
let run = match st.chpx.filter(|r| r.contains(fc)) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
let r = ctx.chpx_bins.chpx_run(ctx.word, fc);
|
||||
st.chpx = Some(r);
|
||||
r
|
||||
}
|
||||
};
|
||||
let grpprl = run.grpprl;
|
||||
let prm = span.map_or(0, |s| s.prm());
|
||||
let style = pb.props.style.as_deref();
|
||||
let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style);
|
||||
if !hit {
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(sty) => &sty.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props });
|
||||
st.generation += 1;
|
||||
}
|
||||
let Some(m) = st.memo.as_ref() else { return };
|
||||
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
|
||||
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
|
||||
let symbol = if c == '(' { symbol_operand(grpprl) } else { None };
|
||||
if field.is_none() && symbol.is_none() {
|
||||
pb.push_shared(c, &m.props, st.generation);
|
||||
return;
|
||||
}
|
||||
let mut props = m.props.clone();
|
||||
let mut c = c;
|
||||
if c == '('
|
||||
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
|
||||
{
|
||||
if let Some((ftc, xchar)) = symbol {
|
||||
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
|
||||
props.font = Some(font.clone());
|
||||
}
|
||||
@@ -900,6 +960,18 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
|
||||
}
|
||||
}
|
||||
|
||||
/// What the walk carries from one character to the next while it resolves formatting: the piece
|
||||
/// and the CHPX run the last character was in (so most characters need no search), and the last
|
||||
/// formatting resolved.
|
||||
#[derive(Default)]
|
||||
struct FmtState<'a> {
|
||||
span: Option<piece::PieceSpan<'a>>,
|
||||
chpx: Option<fkp::ChpxRun<'a>>,
|
||||
memo: Option<FmtMemo<'a>>,
|
||||
/// Counts the memo's replacements, so equal values mean the same properties.
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
|
||||
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
|
||||
/// formatting change, not once per character (#3692).
|
||||
|
||||
@@ -117,12 +117,6 @@ impl PieceTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
|
||||
/// with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
|
||||
self.piece_of(cp).map_or(0, |p| p.prm)
|
||||
}
|
||||
|
||||
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
if i >= self.pieces.len() {
|
||||
@@ -143,6 +137,17 @@ impl PieceTable {
|
||||
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
|
||||
}
|
||||
|
||||
/// The piece holding `cp`, with the CP range it covers: for every CP in that range the
|
||||
/// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone,
|
||||
/// so a sequential walk searches the piece table once per piece.
|
||||
pub(crate) fn span(&self, cp: u32) -> Option<PieceSpan<'_>> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
let pcd = self.pieces.get(i)?;
|
||||
let cp_lo = *self.cps.get(i)?;
|
||||
let cp_hi = *self.cps.get(i + 1)?;
|
||||
Some(PieceSpan { cp_lo, cp_hi, pcd })
|
||||
}
|
||||
|
||||
/// The CP just past the last piece: no text exists at or beyond it.
|
||||
pub(crate) fn cp_end(&self) -> u32 {
|
||||
self.cps.last().copied().unwrap_or(0)
|
||||
@@ -187,6 +192,31 @@ impl PieceTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct PieceSpan<'a> {
|
||||
cp_lo: u32,
|
||||
cp_hi: u32,
|
||||
pcd: &'a Pcd,
|
||||
}
|
||||
|
||||
impl PieceSpan<'_> {
|
||||
pub(crate) fn contains(&self, cp: u32) -> bool {
|
||||
self.cp_lo <= cp && cp < self.cp_hi
|
||||
}
|
||||
|
||||
/// [`PieceTable::fc_of_cp`] for a CP inside the span.
|
||||
pub(crate) fn fc_at(&self, cp: u32) -> Option<u32> {
|
||||
let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?;
|
||||
if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) }
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm(&self) -> u16 {
|
||||
self.pcd.prm
|
||||
}
|
||||
}
|
||||
|
||||
/// What a piece's `Prm` refers to.
|
||||
pub(crate) enum PrmRef<'a> {
|
||||
None,
|
||||
|
||||
@@ -337,6 +337,15 @@ fn empty_document_gets_a_paragraph() {
|
||||
assert_eq!(doc.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
|
||||
let doc = read(&f).expect("opens");
|
||||
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
|
||||
// line breaking.
|
||||
assert_eq!(doc.settings.compat_mode, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_text_stays_out_of_body() {
|
||||
let f = text_doc(
|
||||
@@ -1148,6 +1157,58 @@ fn header_story_becomes_part() {
|
||||
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
|
||||
}
|
||||
|
||||
/// The odd header's blocks for a one-section document whose header story is `story`, with
|
||||
/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`):
|
||||
/// paragraph texts, a table as `<table>`; `None` when the slot has no part.
|
||||
fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> Option<Vec<String>> {
|
||||
let spec = StructSpec {
|
||||
text: "body\r",
|
||||
header_stories: vec![story],
|
||||
sections: vec![(5, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries,
|
||||
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 id = doc.last_section.headers.default?;
|
||||
let part = doc.parts.get(&id).expect("part");
|
||||
Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.text.clone())).collect())
|
||||
}
|
||||
|
||||
/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that
|
||||
/// is not part of its contents. Read as a paragraph, it put an empty line under every header
|
||||
/// and pushed the first body line down when the header reached past the top margin.
|
||||
#[test]
|
||||
fn header_story_guard_mark_is_not_a_paragraph() {
|
||||
let plain = || vec![(0, 0, Vec::new())];
|
||||
// One line typed into the header: content "Header\r" + guard.
|
||||
assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()]));
|
||||
// Enter pressed after it: the empty paragraph is content and stays, only the guard goes.
|
||||
assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()]));
|
||||
// An empty header (one empty content paragraph + guard) is still a header of its own.
|
||||
assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()]));
|
||||
// A story that is only a mark (no guard) keeps its paragraph and its part.
|
||||
assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()]));
|
||||
// A story without a guard keeps its last paragraph.
|
||||
assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()]));
|
||||
}
|
||||
|
||||
/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that
|
||||
/// closes the table, not a guard to drop.
|
||||
#[test]
|
||||
fn header_story_mark_after_a_table_row_is_kept() {
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
|
||||
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat();
|
||||
// Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8).
|
||||
let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())];
|
||||
assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["<table>".to_string(), String::new()]));
|
||||
}
|
||||
|
||||
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
|
||||
/// built per the spec (cb counts the bytes after it, plus one).
|
||||
fn t_def_table(edges: &[i16]) -> Vec<u8> {
|
||||
@@ -2880,3 +2941,153 @@ fn field_results_keep_their_text_and_runs() {
|
||||
p.objects
|
||||
);
|
||||
}
|
||||
|
||||
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
|
||||
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
|
||||
let mut word = vec![0u8; 512];
|
||||
word.extend_from_slice(page);
|
||||
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
|
||||
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
|
||||
(word, bins)
|
||||
}
|
||||
|
||||
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
|
||||
/// reuses it for the following characters without asking again.
|
||||
#[test]
|
||||
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
|
||||
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
|
||||
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
|
||||
for fc in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
let run = bins.chpx_run(&word, fc);
|
||||
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
|
||||
for other in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
if run.contains(other) {
|
||||
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
|
||||
}
|
||||
|
||||
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
|
||||
/// a range that never claims an FC with a different answer.
|
||||
#[test]
|
||||
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
|
||||
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
|
||||
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
|
||||
for fc in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
let run = bins.chpx_run(&word, fc);
|
||||
assert_eq!(run.grpprl, bins.chpx(&word, fc));
|
||||
for other in (fc_of(0) - 4)..(fc_of(70)) {
|
||||
if run.contains(other) {
|
||||
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
|
||||
/// and UTF-16) and past the last.
|
||||
#[test]
|
||||
fn a_piece_span_matches_the_per_cp_lookups() {
|
||||
let doc = text_doc(
|
||||
&[
|
||||
Piece { compressed: true, text: "abc".into() },
|
||||
Piece { compressed: false, text: "d\u{e9}fgh".into() },
|
||||
Piece { compressed: true, text: "ij".into() },
|
||||
],
|
||||
&[],
|
||||
0,
|
||||
);
|
||||
let mut comp = crate::open_container(&doc).expect("container");
|
||||
let table = crate::stream(&mut comp, "0Table").expect("table");
|
||||
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
|
||||
for cp in 0..14 {
|
||||
let span = pieces.span(cp);
|
||||
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
|
||||
for other in 0..14 {
|
||||
if span.is_some_and(|s| s.contains(other)) {
|
||||
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(pieces.span(10).is_none(), "past the last piece");
|
||||
}
|
||||
|
||||
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
|
||||
/// even compared while the formatting stays the same.
|
||||
#[test]
|
||||
fn shared_props_start_one_run_per_formatting() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
for _ in 0..100_000 {
|
||||
pb.push_shared('a', &bold, 1);
|
||||
}
|
||||
pb.push_shared('b', &CharProps::default(), 2);
|
||||
pb.push_shared('c', &bold, 3);
|
||||
pb.push_shared('d', &bold, 4);
|
||||
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
|
||||
pb.push('e', bold.clone());
|
||||
pb.push_shared('f', &bold, 4);
|
||||
assert_eq!(
|
||||
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
|
||||
[100_000, 1, 4],
|
||||
"a plain push forgets the generation but still joins an equal run"
|
||||
);
|
||||
assert_eq!(pb.text.len(), 100_005);
|
||||
}
|
||||
|
||||
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
|
||||
/// run is reported as valid it gives the answer a fresh lookup gives.
|
||||
#[test]
|
||||
fn chpx_runs_are_sound_across_bins() {
|
||||
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
|
||||
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
|
||||
let mut word = vec![0u8; 512];
|
||||
word.extend_from_slice(&page_a);
|
||||
word.extend_from_slice(&page_b);
|
||||
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
|
||||
let plc = |fcs: &[u32], pns: &[u32]| {
|
||||
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
|
||||
};
|
||||
let tables = [
|
||||
("two bins", plc(&[0, a, end], &[1, 2])),
|
||||
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
|
||||
("a missing page", plc(&[0, a, end], &[1, 9])),
|
||||
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
|
||||
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
|
||||
];
|
||||
for (what, t) in &tables {
|
||||
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
|
||||
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
|
||||
for &fc in &probes {
|
||||
let run = bins.chpx_run(&word, fc);
|
||||
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
|
||||
for &other in &probes {
|
||||
if run.contains(other) {
|
||||
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
|
||||
/// (with a field and a symbol character in between), and nowhere else.
|
||||
#[test]
|
||||
fn formatting_runs_break_where_the_chpx_changes() {
|
||||
let spec = RichSpec {
|
||||
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
|
||||
fonts: vec!["Times New Roman", "Arial"],
|
||||
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
|
||||
papx_entries: vec![(30, 0, Vec::new())],
|
||||
};
|
||||
let doc = read(&rich_doc(&spec)).expect("opens");
|
||||
let p = para0(&doc);
|
||||
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
|
||||
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
|
||||
// (the run from the field to CP 20), "d(ee" italic.
|
||||
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
|
||||
}
|
||||
|
||||
@@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
|
||||
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
|
||||
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
|
||||
// white fill.
|
||||
let brace = sp(&[fsp(88, 1026, 0xA00)]);
|
||||
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
|
||||
let rect = sp(&[fsp(1, 1028, 0xA00)]);
|
||||
let art = art_content(&[], &[brace, bracket, rect]);
|
||||
let flags = spa_flags(0, 0, 3, false);
|
||||
let spas =
|
||||
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let fills: Vec<_> = p0
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fills,
|
||||
[
|
||||
(None, Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
|
||||
@@ -350,6 +350,7 @@ impl Package {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -405,11 +406,13 @@ pub struct Rel {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rels {
|
||||
pub list: Vec<Rel>,
|
||||
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
|
||||
ids: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Rels {
|
||||
pub fn by_id(&self, id: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| r.id == id)
|
||||
self.ids.get(id).and_then(|&i| self.list.get(i))
|
||||
}
|
||||
pub fn by_type(&self, full: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| rel_is(&r.kind, full))
|
||||
@@ -547,6 +550,22 @@ mod tests {
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_are_looked_up_by_id_the_first_one_winning() {
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
|
||||
}
|
||||
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
|
||||
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
|
||||
let r = Package::open(&z).unwrap().rels("word/document.xml");
|
||||
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
|
||||
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
|
||||
assert!(r.by_id("rId20000").is_none());
|
||||
// Hyperlink-style lookups, one per relationship, are each a map lookup.
|
||||
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
|
||||
+10
-1
@@ -27,7 +27,8 @@ use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
|
||||
use wordcraft_doc::{
|
||||
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
|
||||
Block, Blocks, Comment, Document, InlineObject, MissingPicture, MissingPictureReason, PagePicture, Paragraph, PartKind, Revision, RevisionKind,
|
||||
Zoom, ZoomFit, para_block,
|
||||
};
|
||||
|
||||
use crate::DocxError;
|
||||
@@ -103,6 +104,8 @@ pub(crate) struct Reader<'p> {
|
||||
pub shape_ids: bool,
|
||||
/// The main document part's name.
|
||||
main: String,
|
||||
/// The part being read (`word/header2.xml`), for naming where a dropped picture was.
|
||||
story: Arc<str>,
|
||||
/// The package's content types, read when first needed.
|
||||
content_types: Option<ContentTypes>,
|
||||
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
|
||||
@@ -183,6 +186,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
glued: HashSet::new(),
|
||||
shape_ids: false,
|
||||
main: main.clone(),
|
||||
story: main.as_str().into(),
|
||||
content_types: None,
|
||||
embedded: EmbeddedManifest::default(),
|
||||
txbx_links: Vec::new(),
|
||||
@@ -190,6 +194,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
group_fill: None,
|
||||
chunks,
|
||||
};
|
||||
r.doc.missing_pictures.main = main.clone();
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
r.pc.minor_font = r.doc.settings.minor_font.clone();
|
||||
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
|
||||
@@ -230,14 +235,18 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
let ids = part(rt::COMMENTS_IDS);
|
||||
let cex = part(rt::COMMENTS_EXTENSIBLE);
|
||||
r.story = p.as_str().into();
|
||||
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
r.story = p.as_str().into();
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
r.story = p.as_str().into();
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
r.story = main.as_str().into();
|
||||
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
|
||||
// package root or the main part; Word's own files sometimes leave the relationship out, so
|
||||
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||
|
||||
@@ -66,6 +66,9 @@ pub(super) struct Axis {
|
||||
|
||||
pub(super) struct Series {
|
||||
pub(super) name: String,
|
||||
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
|
||||
/// when missing or not a valid number.
|
||||
pub(super) idx: Option<usize>,
|
||||
pub(super) color: Option<Rgb>,
|
||||
pub(super) line_w: f32,
|
||||
/// Whether the series is drawn as a line (scatter series without one get markers).
|
||||
|
||||
@@ -91,8 +91,11 @@ impl Chart {
|
||||
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
|
||||
}
|
||||
|
||||
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
|
||||
/// doesn't matter), else the one its place in the file picks.
|
||||
pub(super) fn series_colour(&self, j: usize) -> Rgb {
|
||||
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
|
||||
let s = self.series.get(j);
|
||||
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
|
||||
}
|
||||
|
||||
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
|
||||
@@ -199,6 +202,7 @@ pub(super) fn text_size(space: &El) -> f32 {
|
||||
impl Series {
|
||||
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
|
||||
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
|
||||
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
|
||||
let sp = ser.child("c:spPr");
|
||||
let ln = sp.and_then(|p| p.child("a:ln"));
|
||||
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
|
||||
@@ -219,7 +223,7 @@ impl Series {
|
||||
}
|
||||
}
|
||||
let labels = show_values(ser.child("c:dLbls"));
|
||||
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -528,3 +528,65 @@ fn negative_sections_and_scientific_numbers() {
|
||||
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
|
||||
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
|
||||
}
|
||||
|
||||
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
|
||||
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
|
||||
// `c:order` changes nothing.
|
||||
|
||||
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
|
||||
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
||||
sers.concat()
|
||||
);
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
let bars = filled(&items).iter().map(|b| b.0).collect();
|
||||
let keys = items
|
||||
.iter()
|
||||
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
|
||||
.collect();
|
||||
(bars, keys)
|
||||
}
|
||||
|
||||
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
|
||||
fn renumbered(ser: &str, idx_order: &str) -> String {
|
||||
let start = ser.find("<c:idx").expect("ser writes c:idx");
|
||||
let end = ser.find("<c:tx>").expect("ser writes c:tx");
|
||||
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_series_colours_follow_c_idx() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
|
||||
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
|
||||
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
|
||||
// A lone series with idx 2 is accent3.
|
||||
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&lone).0, [accent(2)]);
|
||||
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
|
||||
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
|
||||
// A series' own colour still wins.
|
||||
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_without_a_usable_c_idx_take_their_place() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// No c:idx at all: the place in the file.
|
||||
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
|
||||
// Junk, negative and out-of-range values: no panic, the place in the file.
|
||||
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
|
||||
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
|
||||
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
|
||||
}
|
||||
// The largest unsignedInt `c:idx` allows draws, without a panic.
|
||||
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
|
||||
assert_eq!(column_colours(&big).0.len(), 1);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,21 @@ impl Reader<'_> {
|
||||
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
|
||||
// is their own already).
|
||||
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
|
||||
// The pictures the chunk's own reader dropped are the file's too (strict export, #6005),
|
||||
// each named by the chunk and its part in it (`word/afchunk1.docx > word/header1.xml`).
|
||||
// A chunk in another format brings none: those importers record nothing yet (#6007).
|
||||
let nested = std::mem::take(&mut doc.missing_pictures);
|
||||
let list_len = nested.list.len() as u64;
|
||||
for mut item in nested.list {
|
||||
let inner = if item.story.is_empty() { nested.main.as_str() } else { item.story.as_str() };
|
||||
item.story = format!("{part} > {inner}");
|
||||
self.doc.missing_pictures.push(item);
|
||||
}
|
||||
// The nested records past its list are the file's too; their stories carry the chunk's
|
||||
// part, so none is one the outer list already counted.
|
||||
let unlisted = nested.total.saturating_sub(list_len);
|
||||
self.doc.missing_pictures.add_unlisted(&part, unlisted);
|
||||
self.doc.missing_pictures.truncated |= nested.truncated;
|
||||
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
|
||||
// Bookmarks and comment marks read before the chunk stay before it.
|
||||
self.flush_pending_before(sc, out, &mut blocks);
|
||||
|
||||
@@ -239,7 +239,13 @@ impl Reader<'_> {
|
||||
/// Keep one part's bytes and content type (not its relationships).
|
||||
fn keep_part(&mut self, part: &str) -> bool {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
|
||||
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
|
||||
if !manifest_field_ok(part)
|
||||
|| lower.ends_with(".rels")
|
||||
|| lower == "[content_types].xml"
|
||||
|| lower.starts_with("wordcraft:")
|
||||
|| part.eq_ignore_ascii_case(&self.main)
|
||||
{
|
||||
log::warn!("docx: not carrying part {part:?} for an embedded object");
|
||||
return false;
|
||||
}
|
||||
@@ -388,6 +394,23 @@ mod tests {
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
|
||||
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
|
||||
#[test]
|
||||
fn a_part_named_like_bookkeeping_is_not_carried() {
|
||||
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
|
||||
let bytes = package(
|
||||
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
|
||||
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
|
||||
);
|
||||
let doc = crate::read(&bytes).unwrap();
|
||||
let roots = unmodelled_roots(&doc);
|
||||
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
|
||||
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
|
||||
assert!(!manifest.contains("evil"), "{manifest}");
|
||||
}
|
||||
|
||||
/// An unqualified attribute is a relationship only where the markup names a relationship
|
||||
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
|
||||
#[test]
|
||||
|
||||
@@ -53,7 +53,10 @@ impl Reader<'_> {
|
||||
// Without content types (a converted package) the root element alone decides.
|
||||
if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) {
|
||||
let rels = self.pkg.rels(&rel.target);
|
||||
// Pictures in a building block belong to the glossary part, not the main story.
|
||||
let outer = std::mem::replace(&mut self.story, rel.target.as_str().into());
|
||||
entries = self.doc_parts(&root, &rels);
|
||||
self.story = outer;
|
||||
}
|
||||
}
|
||||
self.doc.glossary = entries;
|
||||
|
||||
@@ -117,6 +117,7 @@ impl Reader<'_> {
|
||||
// Reserve the id first: the note's own reference mark points at it.
|
||||
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
|
||||
let id = self.doc.add_part(pk, Blocks::new());
|
||||
self.doc.missing_pictures.part_names.insert(id, self.story.clone());
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.current_note = Some((kind, id));
|
||||
@@ -214,6 +215,7 @@ impl Reader<'_> {
|
||||
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
let part = self.doc.add_part(PartKind::Comment, blocks);
|
||||
self.doc.missing_pictures.part_names.insert(part, self.story.clone());
|
||||
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
|
||||
by_para.insert(pid.to_string(), id);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
|
||||
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
|
||||
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, MissingPicture,
|
||||
MissingPictureReason, PartKind, Reader, Rels, Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
@@ -55,6 +55,8 @@ impl Reader<'_> {
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.flat_map(|n| n.split(';'))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
@@ -89,14 +91,22 @@ impl Reader<'_> {
|
||||
|
||||
/// Media key for an image relationship (loads the bytes once).
|
||||
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
|
||||
let rel = rels.by_id(id)?;
|
||||
let Some(rel) = rels.by_id(id) else {
|
||||
self.note_missing_picture(id, MissingPictureReason::MissingPart);
|
||||
return None;
|
||||
};
|
||||
if rel.external {
|
||||
// Never fetched: a picture whose target is outside the file is dropped (and reported).
|
||||
self.note_missing_picture(&rel.target, MissingPictureReason::ExternalTarget);
|
||||
return None;
|
||||
}
|
||||
if let Some(k) = self.media_by_path.get(&rel.target) {
|
||||
return Some(k.clone());
|
||||
}
|
||||
let bytes = self.pkg.get_shared(&rel.target)?;
|
||||
let Some(bytes) = self.pkg.get_shared(&rel.target) else {
|
||||
self.note_missing_picture(&rel.target, MissingPictureReason::MissingPart);
|
||||
return None;
|
||||
};
|
||||
let base: String =
|
||||
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
|
||||
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
|
||||
@@ -110,10 +120,18 @@ impl Reader<'_> {
|
||||
};
|
||||
}
|
||||
self.doc.media.insert(key.clone(), bytes.clone());
|
||||
self.doc.missing_pictures.media_parts.insert(key.clone(), rel.target.clone());
|
||||
self.media_by_path.insert(rel.target.clone(), key.clone());
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Remember a picture that was dropped at open, in the story being read.
|
||||
pub(super) fn note_missing_picture(&mut self, part: &str, reason: MissingPictureReason) {
|
||||
let part: String = part.chars().take(1024).collect();
|
||||
let story = self.story.to_string();
|
||||
self.doc.missing_pictures.push(MissingPicture { part, story, reason });
|
||||
}
|
||||
|
||||
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
|
||||
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
|
||||
if let Some(p) = self.graphic_parts.get(path) {
|
||||
@@ -135,6 +153,8 @@ impl Reader<'_> {
|
||||
let kind = if footer { PartKind::Footer } else { PartKind::Header };
|
||||
let id = self.doc.add_part(kind, Blocks::new());
|
||||
self.hf_by_path.insert(path.to_string(), id);
|
||||
let name: Arc<str> = path.into();
|
||||
self.doc.missing_pictures.part_names.insert(id, Arc::clone(&name));
|
||||
let root = match self.xml(path) {
|
||||
Ok(Some(r)) => r,
|
||||
_ => return Some(id),
|
||||
@@ -144,7 +164,9 @@ impl Reader<'_> {
|
||||
sc.in_hf = true;
|
||||
let mut blocks = Blocks::new();
|
||||
self.hf_depth += 1;
|
||||
let outer = std::mem::replace(&mut self.story, name);
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
|
||||
self.story = outer;
|
||||
self.hf_depth = self.hf_depth.saturating_sub(1);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||
|
||||
@@ -5,16 +5,17 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
|
||||
TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::MAX_FONT_NAME;
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
|
||||
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
|
||||
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
|
||||
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
|
||||
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
|
||||
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
|
||||
/// drop an over-long name.
|
||||
fn bounded_font_list(v: &str) -> Option<String> {
|
||||
if v.len() <= MAX_FONT_NAME {
|
||||
return (!v.is_empty()).then(|| v.to_string());
|
||||
}
|
||||
let mut end = 0usize;
|
||||
for part in v.split_inclusive(';') {
|
||||
let next = end + part.len();
|
||||
if next > MAX_FONT_NAME {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
let kept = v.get(..end)?.trim_end_matches(';');
|
||||
(!kept.is_empty()).then(|| kept.to_string())
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
pub fn style_id(&self, id: &str) -> String {
|
||||
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
||||
@@ -226,6 +250,8 @@ impl PropCtx {
|
||||
shd_fill(e)
|
||||
}
|
||||
|
||||
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
|
||||
/// weight and posture of the complex-script and East Asian text beside them.
|
||||
pub fn rpr(&self, e: &El) -> CharProps {
|
||||
let mut c = CharProps::default();
|
||||
for k in e.els() {
|
||||
@@ -234,7 +260,11 @@ impl PropCtx {
|
||||
"w:rFonts" => {
|
||||
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
// A LibreOffice file names the Latin font as a semicolon-separated fallback
|
||||
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
|
||||
// corrupt or hostile, and every piece of text drawn in it would pay for its
|
||||
// whole length as the list is classified (see `bounded_font_list`).
|
||||
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
@@ -338,6 +368,7 @@ impl PropCtx {
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
|
||||
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
|
||||
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
|
||||
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
|
||||
@@ -376,10 +407,10 @@ impl PropCtx {
|
||||
p.tabs = Some(tabs);
|
||||
}
|
||||
"w:spacing" => {
|
||||
if let Some(v) = tw(k, "w:before") {
|
||||
if let Some(v) = tw_u16(k, "w:before") {
|
||||
p.space_before = Some(v.max(0.0));
|
||||
}
|
||||
if let Some(v) = tw(k, "w:after") {
|
||||
if let Some(v) = tw_u16(k, "w:after") {
|
||||
p.space_after = Some(v.max(0.0));
|
||||
}
|
||||
// HTML-style automatic spacing; "0" switches an inherited one off.
|
||||
@@ -402,15 +433,15 @@ impl PropCtx {
|
||||
}
|
||||
}
|
||||
"w:ind" => {
|
||||
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
|
||||
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
|
||||
p.indent_left = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
|
||||
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
|
||||
p.indent_right = Some(v);
|
||||
}
|
||||
if let Some(v) = tw(k, "w:hanging") {
|
||||
if let Some(v) = tw_i16(k, "w:hanging") {
|
||||
p.indent_first = Some(-v);
|
||||
} else if let Some(v) = tw(k, "w:firstLine") {
|
||||
} else if let Some(v) = tw_i16(k, "w:firstLine") {
|
||||
p.indent_first = Some(v);
|
||||
}
|
||||
// Character units (hundredths of a character), which win when non-zero.
|
||||
|
||||
@@ -41,6 +41,7 @@ impl Reader<'_> {
|
||||
}
|
||||
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
if *e != 0.0 {
|
||||
@@ -106,7 +107,11 @@ impl Reader<'_> {
|
||||
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
|
||||
// Word's effect extent also covers a rotated object's overhang; the model keeps that
|
||||
// apart (it follows from the angle), so take it back out.
|
||||
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
|
||||
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
|
||||
if let Some(f) = obj.float_mut() {
|
||||
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
|
||||
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
|
||||
let (px, py) = f.spin_pad(w, h);
|
||||
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
|
||||
// Zero adds no effects room when rotation contracts this axis.
|
||||
@@ -118,9 +123,33 @@ impl Reader<'_> {
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
|
||||
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
|
||||
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
|
||||
/// part, when the address has one, stays part of the address.
|
||||
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
|
||||
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
|
||||
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
|
||||
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
|
||||
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
|
||||
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
|
||||
/// choice's link is not this object's.
|
||||
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
|
||||
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
|
||||
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
|
||||
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
|
||||
};
|
||||
let link =
|
||||
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
|
||||
Some((object, link))
|
||||
}
|
||||
|
||||
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
|
||||
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
|
||||
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
|
||||
let drawing = choice.child("w:drawing")?;
|
||||
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
|
||||
@@ -149,10 +178,8 @@ impl Reader<'_> {
|
||||
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
|
||||
/// fallback picture, keeping both branches so saving writes the chart back rather than only
|
||||
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
|
||||
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = ac
|
||||
.children("mc:Choice")
|
||||
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
|
||||
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = chartex_choice(ac)?;
|
||||
// A requirement in a namespace we can't name couldn't be written back.
|
||||
let requires = choice.attr("Requires").unwrap_or("");
|
||||
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
|
||||
@@ -252,7 +279,15 @@ impl Reader<'_> {
|
||||
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, mut float: Float) -> Option<InlineObject> {
|
||||
let blip = pic.find("a:blip")?;
|
||||
float.set_spin(xfrm_spin(pic.find("pic:spPr")));
|
||||
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
|
||||
let Some(embed) = blip.attr("r:embed") else {
|
||||
// A linked picture holds no copy: nothing to draw, and nothing is fetched.
|
||||
if let Some(link) = blip.attr("r:link") {
|
||||
let target = rels.by_id(link).map_or_else(|| link.to_string(), |r| r.target.clone());
|
||||
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let media = self.media_for(rels, embed)?;
|
||||
let mut crop = [0.0f32; 4];
|
||||
if let Some(sr) = pic.find("a:srcRect") {
|
||||
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
||||
@@ -563,6 +598,11 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
|
||||
fn chartex_choice(ac: &El) -> Option<&El> {
|
||||
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
|
||||
}
|
||||
|
||||
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
|
||||
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wordcraft_doc::math::MathJc;
|
||||
|
||||
use super::{
|
||||
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
|
||||
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
|
||||
@@ -13,6 +15,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.).
|
||||
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
match k.name.as_str() {
|
||||
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
|
||||
@@ -76,31 +79,44 @@ impl Reader<'_> {
|
||||
}
|
||||
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
|
||||
"m:oMath" => {
|
||||
let mut props = self.run_props_none(ctx);
|
||||
let mut math = crate::read::math::read_omath(k, Default::default());
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_omath(sc, pb, k, Default::default(), false, &props);
|
||||
}
|
||||
"m:oMathPara" => {
|
||||
let props = self.run_props_none(ctx);
|
||||
let base_props = self.run_props_none(ctx);
|
||||
let jc = crate::read::math::para_jc(k);
|
||||
for m in k.children("m:oMath") {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(c) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(c));
|
||||
for c in k.els() {
|
||||
match c.name.as_str() {
|
||||
"m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props),
|
||||
"w:ins" | "w:moveTo" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.ins =
|
||||
Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) });
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
"w:del" | "w:moveFrom" => {
|
||||
let mut sub_ctx = ctx.clone();
|
||||
sub_ctx.del = Some(if c.name == "w:moveFrom" {
|
||||
self.move_revision(false, c, false)
|
||||
} else {
|
||||
self.revision(RevisionKind::Delete, c)
|
||||
});
|
||||
let sub_props = self.run_props_none(&sub_ctx);
|
||||
for m in c.children("m:oMath") {
|
||||
self.emit_omath(sc, pb, m, jc, true, &sub_props);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_obj(sc, pb, o, &props);
|
||||
self.emit_linked_obj(sc, pb, o, &props, link);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
}
|
||||
@@ -237,7 +253,7 @@ impl Reader<'_> {
|
||||
},
|
||||
"w:drawing" => {
|
||||
if let Some(o) = self.read_drawing(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
@@ -298,8 +314,8 @@ impl Reader<'_> {
|
||||
),
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
self.emit_linked_obj(sc, pb, o, props, link);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
|
||||
@@ -376,6 +392,25 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
|
||||
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
|
||||
match link.filter(|_| props.link.is_none()) {
|
||||
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
|
||||
None => self.emit_obj(sc, pb, o, props),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an `m:oMath` element and emit its [`InlineObject::Equation`].
|
||||
fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) {
|
||||
let mut math = crate::read::math::read_omath(m, jc);
|
||||
let mut props = props.clone();
|
||||
if let Some(color) = crate::read::math::lift_color(&mut math) {
|
||||
props.color = Some(TextColor::Rgb(color));
|
||||
}
|
||||
let linear = wordcraft_doc::math::to_linear(&math.nodes);
|
||||
self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props);
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
|
||||
@@ -131,7 +131,14 @@ impl Reader<'_> {
|
||||
None => vml_float(shape, style, sc.in_hf),
|
||||
};
|
||||
if let Some(img) = shape.find("v:imagedata") {
|
||||
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
|
||||
let Some(id) = img.attr("r:id").or_else(|| img.attr("r:pict")) else {
|
||||
// `r:href` names a file outside the package: never fetched.
|
||||
if let Some(href) = img.attr("r:href") {
|
||||
let target = rels.by_id(href).map_or_else(|| href.to_string(), |r| r.target.clone());
|
||||
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let media = self.media_for(rels, id)?;
|
||||
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
|
||||
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
|
||||
@@ -224,6 +231,15 @@ impl Reader<'_> {
|
||||
{
|
||||
let id = self.read_textbox(sc, t, rels);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
|
||||
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
|
||||
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
|
||||
let tb_style = tb.attr("style").unwrap_or("");
|
||||
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
|
||||
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert;
|
||||
}
|
||||
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
|
||||
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
|
||||
// `o:insetmode="auto"` leaves them to the application.
|
||||
|
||||
@@ -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 crate::units::tw_i16;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
@@ -317,12 +318,12 @@ impl Reader<'_> {
|
||||
lv.align = props::align(j);
|
||||
}
|
||||
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
|
||||
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
|
||||
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
|
||||
lv.indent = v;
|
||||
}
|
||||
if let Some(h) = tw(ind, "w:hanging") {
|
||||
if let Some(h) = tw_i16(ind, "w:hanging") {
|
||||
lv.hanging = h;
|
||||
} else if let Some(f) = tw(ind, "w:firstLine") {
|
||||
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
|
||||
lv.hanging = -f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
}
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
|
||||
if empty_heading {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = toc;
|
||||
*body = if empty_heading { toc } else { engine };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
|
||||
e.attr(attr).and_then(|v| measure(v, 20.0))
|
||||
}
|
||||
|
||||
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
|
||||
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
|
||||
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
|
||||
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) => Some(f32::from(i as i16) / 20.0),
|
||||
Err(_) => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
|
||||
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
|
||||
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
|
||||
_ => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
|
||||
pub fn on_off(e: &El) -> bool {
|
||||
match e.attr("w:val") {
|
||||
@@ -148,6 +169,24 @@ pub fn emu(pt: f32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twips_beyond_16_bits_wrap() {
|
||||
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
|
||||
let s = |a: &str| tw_i16(&el(a), "w:v");
|
||||
let u = |a: &str| tw_u16(&el(a), "w:v");
|
||||
assert_eq!(s("66256"), Some(36.0));
|
||||
assert_eq!(s("-65516"), Some(1.0));
|
||||
assert_eq!(s("98304"), Some(-1638.4));
|
||||
assert_eq!(s("32767"), Some(1638.35));
|
||||
assert_eq!(s("-32768"), Some(-1638.4));
|
||||
assert_eq!(s("1in"), Some(72.0));
|
||||
assert_eq!(s("x"), None);
|
||||
assert_eq!(u("65537"), Some(0.05));
|
||||
assert_eq!(u("65535"), Some(3276.75));
|
||||
assert_eq!(u("12pt"), Some(12.0));
|
||||
assert_eq!(u("-20"), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures() {
|
||||
assert_eq!(measure("1440", 20.0), Some(72.0));
|
||||
|
||||
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
|
||||
};
|
||||
self.embeds.markup_bytes = left;
|
||||
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
|
||||
let mut used = Vec::new();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
// Every relationship the markup uses must be writable, or the object would be broken.
|
||||
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
|
||||
if !used.iter().all(writable) {
|
||||
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
|
||||
if !used.iter().all(|id| writable.contains(id.as_str())) {
|
||||
log::warn!("docx: an embedded object's parts are missing; it is left out");
|
||||
return None;
|
||||
}
|
||||
@@ -499,8 +499,9 @@ impl super::Writer<'_> {
|
||||
}
|
||||
}
|
||||
"wp:effectExtent" => {
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
|
||||
// unless the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let ext = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
|
||||
@@ -637,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
|
||||
/// never went through the reader's id limit) costs no scan per attribute.
|
||||
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &e.attrs {
|
||||
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
|
||||
out.push(v.clone());
|
||||
out.insert(v.clone());
|
||||
}
|
||||
}
|
||||
for c in e.els() {
|
||||
@@ -851,6 +854,24 @@ fn valid_part_name(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relationship_ids_are_collected_once_each() {
|
||||
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
|
||||
// (each named twice) were a scan of every id collected so far, per attribute.
|
||||
let mut xml = String::from(
|
||||
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
for i in 0..40_000 {
|
||||
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
|
||||
}
|
||||
xml.push_str("</w:p>");
|
||||
let root = xml::parse(xml.as_bytes()).unwrap();
|
||||
let mut used = HashSet::new();
|
||||
rel_attr_values(&root, &mut used, 0);
|
||||
assert_eq!(used.len(), 40_000);
|
||||
assert!(used.contains("rId39999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charts_cut_to_the_limits_on_save_say_so_once() {
|
||||
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
|
||||
|
||||
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(direction) = p.text_direction {
|
||||
w.val("w:textDirection", direction.ooxml());
|
||||
}
|
||||
if let Some(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
|
||||
@@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
|
||||
})
|
||||
};
|
||||
// Bookmark, comment and permission markers after the end may stay there: a body-level
|
||||
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
|
||||
// and the control still covers the whole paragraph.
|
||||
let at_end = |b: usize, off: usize| {
|
||||
use wordcraft_doc::InlineObject as O;
|
||||
para(b).is_some_and(|p| {
|
||||
let from = off + OBJ.len_utf8();
|
||||
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
|
||||
matches!(
|
||||
p.object_at(o),
|
||||
Some(
|
||||
O::ControlEnd
|
||||
| O::BookmarkStart { .. }
|
||||
| O::BookmarkEnd { .. }
|
||||
| O::CommentStart { .. }
|
||||
| O::CommentEnd { .. }
|
||||
| O::PermStart { .. }
|
||||
| O::PermEnd { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut mode = vec![Mode::Inline; ranges.len()];
|
||||
|
||||
@@ -10,9 +10,19 @@ pub(super) struct DrawingInfo<'a> {
|
||||
pub docpr: &'a str,
|
||||
pub name: &'a str,
|
||||
pub alt: &'a str,
|
||||
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
|
||||
pub link: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
|
||||
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
|
||||
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
|
||||
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
|
||||
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
|
||||
Some(rels.add(rt::HYPERLINK, url, true))
|
||||
}
|
||||
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
|
||||
@@ -20,7 +30,7 @@ impl Writer<'_> {
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
@@ -66,7 +76,7 @@ impl Writer<'_> {
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = shape_name(*kind, freeform.as_deref(), id);
|
||||
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
|
||||
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
|
||||
}
|
||||
if kind.is_connector() {
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
|
||||
@@ -235,7 +245,7 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
if float.wrap == Wrap::Inline {
|
||||
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
|
||||
@@ -288,8 +298,9 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(iw, ih);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
|
||||
// the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
|
||||
@@ -316,7 +327,7 @@ impl Writer<'_> {
|
||||
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
|
||||
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
|
||||
}
|
||||
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
|
||||
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,8 +513,9 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
|
||||
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
|
||||
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
|
||||
/// from a file with no description) is not written twice.
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
|
||||
/// from a file with no description) is not written twice. `link` is the relationship id of the
|
||||
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
|
||||
let mut attrs = vec![("id", id), ("name", name)];
|
||||
if descr != alt.title || descr.is_empty() {
|
||||
attrs.push(("descr", descr));
|
||||
@@ -514,16 +526,21 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, des
|
||||
if hidden {
|
||||
attrs.push(("hidden", "1"));
|
||||
}
|
||||
if !alt.decorative {
|
||||
if !alt.decorative && link.is_none() {
|
||||
w.empty(tag, &attrs);
|
||||
return;
|
||||
}
|
||||
w.open(tag, &attrs);
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
if let Some(rid) = link {
|
||||
w.empty("a:hlinkClick", &[("r:id", rid)]);
|
||||
}
|
||||
if alt.decorative {
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::section::comment_reference_style;
|
||||
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
|
||||
|
||||
impl Writer<'_> {
|
||||
/// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.).
|
||||
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
match o {
|
||||
// Written by `para_content` (inline) or `blocks` (around paragraphs).
|
||||
@@ -51,10 +52,12 @@ impl Writer<'_> {
|
||||
Some(TextColor::Rgb(c)) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
self.rev_open(w, props);
|
||||
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
|
||||
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
|
||||
None => crate::write::math::write_equation(w, linear, *display, math, color),
|
||||
}
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
|
||||
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
|
||||
@@ -220,13 +223,18 @@ impl Writer<'_> {
|
||||
w.open("mc:Choice", &[("Requires", requires)]);
|
||||
}
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
let z = self.z;
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
native.unwrap_or(&root),
|
||||
);
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
match picture {
|
||||
@@ -234,7 +242,11 @@ impl Writer<'_> {
|
||||
// Both branches are one object: same id and stacking order.
|
||||
self.z = z;
|
||||
w.open("mc:Fallback", &[]);
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
picture,
|
||||
);
|
||||
w.close("mc:Fallback");
|
||||
}
|
||||
None => w.raw(&fallback.to_xml()),
|
||||
@@ -266,8 +278,9 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("Picture {docpr}");
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
@@ -355,6 +368,7 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.shape_docpr(extra.id);
|
||||
let link = Self::drawing_link(props, rels);
|
||||
// Ink is told apart by its name (and its pen), which reading looks for.
|
||||
let name = shape_name(*kind, freeform.as_deref(), &docpr);
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
@@ -369,7 +383,7 @@ impl Writer<'_> {
|
||||
.map(std::sync::Arc::new);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
@@ -389,8 +403,9 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
let outer = match canvas {
|
||||
|
||||
+114
-6
@@ -94,6 +94,49 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
||||
"?"
|
||||
}
|
||||
|
||||
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
|
||||
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
|
||||
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
|
||||
|
||||
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
|
||||
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
|
||||
/// for Strict documents (#3548):
|
||||
///
|
||||
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
|
||||
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
|
||||
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
|
||||
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
|
||||
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
|
||||
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
|
||||
/// schema's name) and from Word's `wne:txbxContent`.
|
||||
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
|
||||
match prefix_for(uri, extra) {
|
||||
"wp" => drawing_prefix(local, parent),
|
||||
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
|
||||
p => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
|
||||
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
|
||||
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
|
||||
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
|
||||
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
|
||||
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
|
||||
match (parent, local) {
|
||||
(_, "wsp") => "wps",
|
||||
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
|
||||
(_, "wpc") => "wpc",
|
||||
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
|
||||
(Some("wps:txbx"), "txbxContent") => "w",
|
||||
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
|
||||
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
|
||||
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
|
||||
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
|
||||
_ => "wp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
|
||||
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
|
||||
fn requirement_prefix(uri: &[u8]) -> &'static str {
|
||||
@@ -342,8 +385,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
loop {
|
||||
buf.clear();
|
||||
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let el_prefix = match &res {
|
||||
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
|
||||
let el_ns = match &res {
|
||||
ResolveResult::Bound(ns) => Some(ns.as_ref()),
|
||||
_ => None,
|
||||
};
|
||||
match ev {
|
||||
@@ -356,8 +399,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
}
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
|
||||
let name = match el_prefix {
|
||||
Some(p) => format!("{p}:{local}"),
|
||||
let name = match el_ns {
|
||||
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
|
||||
None => local,
|
||||
};
|
||||
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
|
||||
@@ -419,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
attach(&mut stack, &mut root, el);
|
||||
}
|
||||
}
|
||||
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
|
||||
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
|
||||
// ` ` is a character reference and stays.
|
||||
Event::Text(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
|
||||
let s = t
|
||||
.xml_content(XmlVersion::Explicit1_0)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
@@ -593,6 +643,42 @@ mod tests {
|
||||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
}
|
||||
|
||||
/// Every element name in `e`, depth first.
|
||||
fn names(e: &El, out: &mut Vec<String>) {
|
||||
out.push(e.name.clone());
|
||||
for c in e.els() {
|
||||
names(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_drawing_names_read_as_word_2010_names() {
|
||||
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
|
||||
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
|
||||
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
|
||||
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
|
||||
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
|
||||
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
|
||||
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
|
||||
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
|
||||
</a:graphicData></a:graphic></d:anchor>
|
||||
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
|
||||
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
|
||||
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
|
||||
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
|
||||
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
|
||||
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
|
||||
assert_eq!(got.join(" "), want);
|
||||
// The Transitional namespaces read as before.
|
||||
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
@@ -645,6 +731,28 @@ mod tests {
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||||
#[test]
|
||||
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
|
||||
let x =
|
||||
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
|
||||
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR.
|
||||
#[test]
|
||||
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
|
||||
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc d<![CDATA[e\r\nf]]></x:t></x:document>";
|
||||
let e = parse(x).unwrap();
|
||||
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
|
||||
// The writer escapes a CR, so a CR in the text survives a save.
|
||||
let x = format!(
|
||||
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
|
||||
esc("a\r\nb\rc")
|
||||
);
|
||||
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
|
||||
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
|
||||
|
||||
@@ -372,3 +372,74 @@ fn hostile_chunks_never_panic() {
|
||||
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
|
||||
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
|
||||
}
|
||||
|
||||
/// A DOCX whose only picture names a part the package lacks (`media/gone.png`), or sits outside it.
|
||||
fn inner_with_dropped_picture(external: bool) -> Vec<u8> {
|
||||
inner_with_dropped_pictures(1, external)
|
||||
}
|
||||
|
||||
/// As [`inner_with_dropped_picture`], with `n` pictures each naming its own missing part.
|
||||
fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec<u8> {
|
||||
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
|
||||
let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect();
|
||||
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p><w:sectPr/></w:body></w:document>"#);
|
||||
let mode = if external { r#" TargetMode="External""# } else { "" };
|
||||
let image = |i: usize| {
|
||||
let target = if external { format!("http://127.0.0.1:9/never-fetched{i}.png") } else { format!("media/gone{i}.png") };
|
||||
format!(
|
||||
r#"<Relationship Id="rIdP{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/>"#
|
||||
)
|
||||
};
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#,
|
||||
(0..n).map(image).collect::<String>()
|
||||
);
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", rels.as_bytes()),
|
||||
] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A picture the chunk's own reader dropped is the file's too (#6005), named by the chunk part.
|
||||
#[test]
|
||||
fn a_picture_dropped_inside_a_chunk_is_recorded_against_the_chunk() {
|
||||
for (external, reason) in [(false, wordcraft_doc::MissingPictureReason::MissingPart), (true, wordcraft_doc::MissingPictureReason::ExternalTarget)]
|
||||
{
|
||||
let inner = inner_with_dropped_picture(external);
|
||||
let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)));
|
||||
let d = read(&bytes).unwrap();
|
||||
let got: Vec<(&str, &str, _)> = d.missing_pictures.list.iter().map(|m| (m.part.as_str(), m.story.as_str(), m.reason)).collect();
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1, "word/afchunk.docx > word/document.xml");
|
||||
assert_eq!(got[0].2, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk with more dropped pictures than the list holds still counts them all (#6005).
|
||||
#[test]
|
||||
fn a_chunks_total_of_dropped_pictures_counts_past_the_list() {
|
||||
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
|
||||
let inner = inner_with_dropped_pictures(n, false);
|
||||
let d = read(&package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
|
||||
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
|
||||
assert!(d.missing_pictures.truncated);
|
||||
assert_eq!(d.missing_pictures.total, n as u64);
|
||||
}
|
||||
|
||||
/// The same chunk imported twice counts its unlisted pictures once, as it dedupes its listed ones.
|
||||
#[test]
|
||||
fn a_chunk_imported_twice_counts_its_dropped_pictures_past_the_list_once() {
|
||||
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
|
||||
let inner = inner_with_dropped_pictures(n, false);
|
||||
let d = read(&package(&format!("{}{CHUNK}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
|
||||
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
|
||||
assert_eq!(d.missing_pictures.total, n as u64);
|
||||
}
|
||||
|
||||
@@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2"))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
|
||||
// not gain a Table of Contents w:sdt on save.
|
||||
let bare = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
p(&t("Chapter One")),
|
||||
);
|
||||
let d_bare = read(&package(&bare)).unwrap();
|
||||
let xml_bare = document_xml(&write(&d_bare).unwrap());
|
||||
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
|
||||
|
||||
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
|
||||
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
|
||||
let sect_toc = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap();
|
||||
assert!(controls(&d_sect).is_empty());
|
||||
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
|
||||
let xml_sect = document_xml(&write(&d_sect).unwrap());
|
||||
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
|
||||
}
|
||||
|
||||
@@ -1766,12 +1766,27 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
|
||||
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
|
||||
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
|
||||
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
|
||||
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
|
||||
chartex_docx_linked(requires, requires_ns, false)
|
||||
}
|
||||
|
||||
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
|
||||
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
|
||||
/// takes the chart's choice.
|
||||
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
|
||||
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
|
||||
let other = if link {
|
||||
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let frame = format!(
|
||||
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
|
||||
);
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
);
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
|
||||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
|
||||
let types = format!(
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
|
||||
@@ -1827,6 +1842,25 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
|
||||
/// and saving keeps it on both branches (the writer rebuilds their frames).
|
||||
#[test]
|
||||
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
|
||||
for _ in 0..2 {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
|
||||
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
let body = text(&files, "word/document.xml");
|
||||
let ids = attr_values(&body, "a:hlinkClick", "r:id");
|
||||
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
|
||||
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_office_2016_chart_resizes_its_fallback_too() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
|
||||
@@ -2462,6 +2496,31 @@ fn east_asian_number_formats_read_label_and_round_trip() {
|
||||
assert_eq!(formats(&back), want);
|
||||
}
|
||||
|
||||
/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that
|
||||
/// text, as in Word, and keeps both through a save.
|
||||
#[test]
|
||||
fn a_none_level_s_literal_text_is_its_label() {
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
let numbering = format!(
|
||||
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="none"/><w:lvlText w:val="RESPONSE:"/><w:pPr><w:ind w:left="1440" w:hanging="1440"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
|
||||
);
|
||||
let body = r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>X</w:t></w:r></w:p>"#;
|
||||
let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
|
||||
let label = |d: &Document| {
|
||||
let n = paras(d).first().and_then(|p| p.props.numbering)?;
|
||||
Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0)
|
||||
};
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(label(&d).as_deref(), Some("RESPONSE:"));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)),
|
||||
Some((NumFormat::None, "RESPONSE:", 72.0, 72.0))
|
||||
);
|
||||
assert_eq!(label(&back).as_deref(), Some("RESPONSE:"));
|
||||
}
|
||||
|
||||
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
|
||||
/// the table's last cell) and is saved as a matched pair.
|
||||
#[test]
|
||||
@@ -3888,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
|
||||
/// within the bound, so the family a real list would pick survives while a hostile list cannot
|
||||
/// cost its whole length in every piece of text drawn in it.
|
||||
#[test]
|
||||
fn a_hostile_fallback_list_keeps_its_leading_families() {
|
||||
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
let list = format!("{};Source Serif 4", candidates.join(";"));
|
||||
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
|
||||
let d = read_body(&body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
let kept = c.font.as_deref().expect("the leading families are kept");
|
||||
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
|
||||
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
|
||||
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
|
||||
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
|
||||
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
|
||||
}
|
||||
|
||||
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
|
||||
#[test]
|
||||
fn a_real_fallback_list_is_read_whole() {
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
#[test]
|
||||
fn roman_fonts_are_read_from_the_font_table() {
|
||||
@@ -3897,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() {
|
||||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||||
}
|
||||
|
||||
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
|
||||
#[test]
|
||||
fn roman_fonts_split_fallback_lists() {
|
||||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||||
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
|
||||
}
|
||||
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||||
#[test]
|
||||
fn html_auto_spacing_off_is_read_and_kept() {
|
||||
@@ -4295,6 +4390,54 @@ fn zero_effects_stay_zero_rotated() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
|
||||
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
|
||||
/// its rotated bounds.
|
||||
#[test]
|
||||
fn bare_turns_are_kept_through_a_save() {
|
||||
let pic = |effects: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let read =
|
||||
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
|
||||
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
|
||||
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
|
||||
let d = read(&pic(effects));
|
||||
assert!(bare(&d), "{effects:?}");
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let body = text(&unzip(&out), "word/document.xml");
|
||||
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
}
|
||||
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
|
||||
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
|
||||
assert!(!bare(&d));
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
|
||||
assert!(!f.bare_turn, "turned here: the rotated bounds again");
|
||||
// A shadow's room added later: saved, and so laid out, with the turn's room again.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.effect = [2.0; 4];
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.wrap = wordcraft_doc::para::Wrap::Square;
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
|
||||
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
|
||||
assert!(!bare(&read(&anchored)));
|
||||
}
|
||||
|
||||
/// The math namespace, declared on the math element itself (the fixture root doesn't).
|
||||
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
|
||||
|
||||
|
||||
@@ -560,3 +560,23 @@ fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
|
||||
}
|
||||
|
||||
/// A picture in a building block that names a part the package lacks is recorded against the
|
||||
/// glossary part, not the main document (#6005).
|
||||
#[test]
|
||||
fn a_missing_picture_in_a_building_block_names_the_glossary_part() {
|
||||
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
|
||||
let part =
|
||||
format!(r#"<w:docPart><w:docPartPr><w:name w:val="Logo"/></w:docPartPr><w:docPartBody><w:p>{drawing}</w:p></w:docPartBody></w:docPart>"#);
|
||||
let bytes = package(
|
||||
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
|
||||
&[
|
||||
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
|
||||
("word/glossary/document.xml", glossary_xml(&part)),
|
||||
("word/glossary/_rels/document.xml.rels", rels(&[("rIdPic", &format!("{REL}/image"), "media/gone.png")])),
|
||||
],
|
||||
);
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
let stories: Vec<&str> = d.missing_pictures.list.iter().map(|m| m.story.as_str()).collect();
|
||||
assert_eq!(stories, ["word/glossary/document.xml"], "{:?}", d.missing_pictures.list);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number"
|
||||
//! character style that formats the numbers (#6018).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering};
|
||||
use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor};
|
||||
|
||||
/// Every line-numbering option and a localised "line number" style (German Word calls its id
|
||||
/// `Zeilennummer`) survive a save and reopen, beside two columns.
|
||||
#[test]
|
||||
fn line_numbering_options_and_style_round_trip() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous };
|
||||
d.last_section.line_numbers = Some(numbering.clone());
|
||||
d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
|
||||
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "Zeilennummer".into(),
|
||||
name: "Line Number".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(back.last_section.line_numbers, Some(numbering));
|
||||
assert_eq!(back.last_section.columns.count, 2);
|
||||
let st = back.styles.find("line number").expect("the line number style");
|
||||
assert_eq!(st.id, "Zeilennummer");
|
||||
assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true)));
|
||||
}
|
||||
@@ -20,9 +20,13 @@ mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod line_numbering;
|
||||
mod malformed;
|
||||
mod missing_pictures;
|
||||
mod nested_block_controls;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
@@ -34,9 +38,11 @@ mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod strict_drawings;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod twips_wrap;
|
||||
mod undefined_style;
|
||||
mod vml_shapes;
|
||||
mod word_xml;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Pictures dropped at open are recorded for strict export (#6005): a missing part, an external
|
||||
//! target and a link-only picture (ECMA-376 Part 1 §20.1.8.13 `a:blip` `r:embed`/`r:link`).
|
||||
//! Original synthetic packages; nothing is fetched, the reader has no network code.
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::{Document, InlineObject, MissingPictureReason};
|
||||
|
||||
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:v="urn:schemas-microsoft-com:vml""#;
|
||||
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn blip(attrs: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip {attrs}/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A package: `body` in `word/document.xml` (whose relationships are `doc_rels`), an optional
|
||||
/// header with `header` as its content (relationships `header_rels`) and `extra` parts.
|
||||
fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let hdr_rel = if header.is_some() { format!(r#"<Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/>"#) } else { String::new() };
|
||||
let sect = if header.is_some() { r#"<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"# } else { "<w:sectPr/>" };
|
||||
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p>{sect}</w:body></w:document>"#);
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
|
||||
);
|
||||
let drels = format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{doc_rels}{hdr_rel}</Relationships>"#);
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut put = |name: &str, bytes: &[u8]| {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
};
|
||||
put("[Content_Types].xml", types.as_bytes());
|
||||
put("_rels/.rels", rels.as_bytes());
|
||||
put("word/document.xml", doc.as_bytes());
|
||||
put("word/_rels/document.xml.rels", drels.as_bytes());
|
||||
if let Some((content, hrels)) = header {
|
||||
put("word/header2.xml", format!(r#"<w:hdr {NS}><w:p>{content}</w:p></w:hdr>"#).as_bytes());
|
||||
put(
|
||||
"word/_rels/header2.xml.rels",
|
||||
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{hrels}</Relationships>"#).as_bytes(),
|
||||
);
|
||||
}
|
||||
for (n, b) in extra {
|
||||
put(n, b);
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn image_rel(id: &str, target: &str, external: bool) -> String {
|
||||
let mode = if external { r#" TargetMode="External""# } else { "" };
|
||||
format!(r#"<Relationship Id="{id}" Type="{REL}/image" Target="{target}"{mode}/>"#)
|
||||
}
|
||||
|
||||
fn missing(d: &Document) -> Vec<(String, String, MissingPictureReason)> {
|
||||
d.missing_pictures.list.iter().map(|m| (m.part.clone(), m.story.clone(), m.reason)).collect()
|
||||
}
|
||||
|
||||
fn pictures(d: &Document) -> usize {
|
||||
d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_missing_and_link_only_pictures_are_recorded_and_still_dropped() {
|
||||
let body = [blip(r#"r:embed="rIdExt""#), blip(r#"r:embed="rIdGone""#), blip(r#"r:link="rIdLink""#), blip(r#"r:embed="rIdNone""#)].concat();
|
||||
let rels = [
|
||||
image_rel("rIdExt", "http://127.0.0.1:9/never-fetched.png", true),
|
||||
image_rel("rIdGone", "media/absent.png", false),
|
||||
image_rel("rIdLink", "file:///C:/pics/linked.png", true),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
|
||||
assert_eq!(pictures(&d), 0, "nothing is drawn for them, as before");
|
||||
assert_eq!(
|
||||
missing(&d),
|
||||
[
|
||||
("http://127.0.0.1:9/never-fetched.png".into(), "word/document.xml".into(), MissingPictureReason::ExternalTarget),
|
||||
("word/media/absent.png".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
|
||||
("file:///C:/pics/linked.png".into(), "word/document.xml".into(), MissingPictureReason::LinkOnly),
|
||||
("rIdNone".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
|
||||
]
|
||||
);
|
||||
assert!(!d.missing_pictures.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_pictures_story_is_the_header_part() {
|
||||
let rels = [image_rel("rIdHx", "https://example.invalid/logo.png", true), image_rel("rIdOk", "media/ok.png", false)].concat();
|
||||
let header = [blip(r#"r:embed="rIdHx""#), blip(r#"r:embed="rIdOk""#)].concat();
|
||||
let bytes = package("", "", Some((&header, &rels)), &[("word/media/ok.png", png())]);
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(missing(&d), [("https://example.invalid/logo.png".into(), "word/header2.xml".into(), MissingPictureReason::ExternalTarget)]);
|
||||
assert_eq!(d.missing_pictures.main, "word/document.xml");
|
||||
assert!(d.missing_pictures.part_names.values().any(|n| &**n == "word/header2.xml"));
|
||||
assert_eq!(d.missing_pictures.media_parts.get("ok.png").map(String::as_str), Some("word/media/ok.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_that_is_there_is_not_recorded() {
|
||||
let body = blip(r#"r:embed="rIdOk""#);
|
||||
let d = wordcraft_docx::read(&package(&body, &image_rel("rIdOk", "media/ok.png", false), None, &[("word/media/ok.png", png())])).unwrap();
|
||||
assert_eq!(pictures(&d), 1);
|
||||
assert!(d.missing_pictures.list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_image_data_links_are_recorded() {
|
||||
let pict =
|
||||
|attrs: &str| format!(r#"<w:r><w:pict><v:shape id="s" style="width:50pt;height:50pt"><v:imagedata {attrs}/></v:shape></w:pict></w:r>"#);
|
||||
let body = [pict(r#"r:id="rIdExt""#), pict(r#"r:href="rIdLink""#), pict(r#"r:id="rIdGone""#)].concat();
|
||||
let rels = [
|
||||
image_rel("rIdExt", "https://example.invalid/a.png", true),
|
||||
image_rel("rIdLink", "https://example.invalid/b.png", true),
|
||||
image_rel("rIdGone", "media/none.png", false),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
|
||||
let got: Vec<_> = missing(&d).into_iter().map(|(p, _, r)| (p, r)).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
("https://example.invalid/a.png".to_string(), MissingPictureReason::ExternalTarget),
|
||||
("https://example.invalid/b.png".to_string(), MissingPictureReason::LinkOnly),
|
||||
("word/media/none.png".to_string(), MissingPictureReason::MissingPart),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_is_capped_and_the_document_is_still_equal() {
|
||||
let n = wordcraft_doc::MAX_MISSING_PICTURES + 5;
|
||||
let body: String = (0..n).map(|i| blip(&format!(r#"r:embed="r{i}""#))).collect();
|
||||
let rels: String = (0..n).map(|i| image_rel(&format!("r{i}"), &format!("media/gone{i}.png"), false)).collect();
|
||||
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
|
||||
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
|
||||
assert!(d.missing_pictures.truncated);
|
||||
// Not part of the document: `==` and the clone ignore it, and a save doesn't carry it.
|
||||
let mut e = d.clone();
|
||||
e.missing_pictures = Default::default();
|
||||
assert_eq!(d, e);
|
||||
assert!(!serde_json::to_string(&d).unwrap().contains("gone"));
|
||||
}
|
||||
|
||||
/// Every note of a part shares the one allocation of its source name, so a file with many notes
|
||||
/// in a part with a long name does not keep a copy of the name per note.
|
||||
#[test]
|
||||
fn the_notes_of_a_part_share_one_copy_of_its_name() {
|
||||
let notes: String = (1..=3).map(|i| format!(r#"<w:footnote w:id="{i}"><w:p><w:r><w:t>n{i}</w:t></w:r></w:p></w:footnote>"#)).collect();
|
||||
let rels = format!(r#"<Relationship Id="rIdF" Type="{REL}/footnotes" Target="footnotes.xml"/>"#);
|
||||
let part = format!(r#"<w:footnotes {NS}>{notes}</w:footnotes>"#);
|
||||
let d = wordcraft_docx::read(&package("x", &rels, None, &[("word/footnotes.xml", part.into_bytes())])).unwrap();
|
||||
let names: Vec<_> = d.missing_pictures.part_names.values().collect();
|
||||
assert!(names.len() >= 3, "{names:?}");
|
||||
assert!(names.iter().all(|n| &***n == "word/footnotes.xml"));
|
||||
assert!(names.windows(2).all(|w| std::sync::Arc::ptr_eq(w[0], w[1])), "each note holds its own copy of the part name");
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A body-level content control nested directly in another body-level one stays body-level when
|
||||
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
|
||||
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
const NS: &str =
|
||||
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
|
||||
const ROOT_RELS: &str = 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>"#;
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
|
||||
}
|
||||
fn p(inner: &str) -> String {
|
||||
format!("<w:p>{inner}</w:p>")
|
||||
}
|
||||
fn sdt(pr: &str, content: &str) -> String {
|
||||
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
|
||||
}
|
||||
|
||||
fn controls(d: &Document) -> Vec<ContentControl> {
|
||||
let mut out = Vec::new();
|
||||
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// How many `w:p` elements are open at byte `at` of `xml`.
|
||||
fn open_paragraphs(xml: &str, at: usize) -> usize {
|
||||
let head = &xml[..at];
|
||||
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
|
||||
opened - head.matches("</w:p>").count()
|
||||
}
|
||||
|
||||
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
|
||||
fn depth_of_control(xml: &str, marker: &str) -> usize {
|
||||
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
|
||||
let start = xml[..at].rfind("<w:sdt>").expect("control start");
|
||||
open_paragraphs(xml, start)
|
||||
}
|
||||
|
||||
fn round_trip(body: &str) -> (Document, Document, String) {
|
||||
let d = read(&package(body)).unwrap();
|
||||
let bytes = write(&d).unwrap();
|
||||
let back = read(&bytes).unwrap();
|
||||
(d, back, document_xml(&bytes))
|
||||
}
|
||||
|
||||
fn repeating_section() -> String {
|
||||
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
|
||||
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
|
||||
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
|
||||
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
|
||||
let (_, _, xml) = round_trip(&repeating_section());
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
// The text control stays inside the paragraph, and the item holds that paragraph.
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
|
||||
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_round_trips_its_controls_and_text() {
|
||||
let (d, back, _) = round_trip(&repeating_section());
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
assert_eq!(back.body.len(), d.body.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
|
||||
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_block_controls_in_a_table_cell_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
|
||||
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
|
||||
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
|
||||
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_level_control_stays_inside_its_paragraph() {
|
||||
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
|
||||
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
|
||||
}
|
||||
|
||||
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
|
||||
/// the body-level `_GoBack` bookmark Word leaves after the last control.
|
||||
fn bound_section_body(bound: bool, bookmark: bool) -> String {
|
||||
let section_binding = if bound {
|
||||
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
|
||||
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
|
||||
let field = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
if bound { bind("/a[1]/n[1]") } else { String::new() },
|
||||
t("note 1")
|
||||
);
|
||||
let item = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
p(&format!("{field}{}", t(" ")))
|
||||
);
|
||||
let section = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
|
||||
);
|
||||
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
|
||||
}
|
||||
|
||||
fn assert_section_block_level(body: &str) {
|
||||
let (d, back, xml) = round_trip(body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
|
||||
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
|
||||
let (d, back, xml) = round_trip(&body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
// The bookmark is kept.
|
||||
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
|
||||
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
|
||||
//! synthetic packages; the 2×2 picture is generated in code.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// An inline picture's run; `link` goes into its `wp:docPr`.
|
||||
fn picture_run(id: u32, link: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let rels = 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 doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let png = png();
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("word/document.xml", xml.as_bytes()),
|
||||
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
|
||||
("word/media/square.png", png.as_slice()),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The link on each picture of the first paragraph, in order.
|
||||
fn picture_links(doc: &Document) -> Vec<Option<String>> {
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
p.object_offsets()
|
||||
.into_iter()
|
||||
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
|
||||
.map(|off| p.props_of_char(off).link.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_on_a_pictures_drawing_is_read() {
|
||||
let body = format!(
|
||||
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
|
||||
// Word's way: the link on the drawing.
|
||||
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
// A click naming a relationship that is no hyperlink, and one naming none.
|
||||
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
|
||||
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
|
||||
// A link around the run wins over the drawing's.
|
||||
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
);
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
|
||||
let mut doc = Document::new();
|
||||
doc.media.insert("square.png".into(), Arc::new(png()));
|
||||
let picture = || InlineObject::Image {
|
||||
media: "square.png".into(),
|
||||
w: 20.0,
|
||||
h: 20.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
|
||||
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let xml = part(&bytes, "word/document.xml");
|
||||
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
|
||||
assert_eq!(clicks.len(), 1, "{xml}");
|
||||
let rid = clicks[0].split('"').nth(1).unwrap();
|
||||
let docpr = xml.find("<wp:docPr ").unwrap();
|
||||
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
|
||||
let rels = part(&bytes, "word/_rels/document.xml.rels");
|
||||
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
|
||||
// The run's own hyperlink names the same relationship, so the address is stored once.
|
||||
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
|
||||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
|
||||
// The drawing's link alone (as Word writes it) reads back the same.
|
||||
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
|
||||
assert_ne!(without_run_link, xml);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if name == "word/document.xml" {
|
||||
data = without_run_link.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
let edited = out.finish().unwrap().into_inner();
|
||||
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
|
||||
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -195,6 +195,7 @@ fn every_para_prop_round_trips() {
|
||||
suppress_hyphens: Some(true),
|
||||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
text_direction: Some(ParagraphTextDirection::TbRl),
|
||||
drop_cap: None,
|
||||
drop_cap_margin: None,
|
||||
drop_cap_distance: None,
|
||||
@@ -993,6 +994,80 @@ fn tracked_changes_round_trip() {
|
||||
assert!(r.settings.track_changes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_equations_round_trip() {
|
||||
// Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
d.settings.track_changes = true;
|
||||
let ins = CharProps { ins: Some(0), ..Default::default() };
|
||||
let del = CharProps { del: Some(1), ..Default::default() };
|
||||
let plain = CharProps::default();
|
||||
let mut p = Paragraph::with_text("Formula: ", CharProps::default());
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
|
||||
assert!(
|
||||
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
|
||||
"del directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(
|
||||
xml.contains(r#"<w:ins w:id="3" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMathPara>"#),
|
||||
"ins directly encloses display math: {xml}"
|
||||
);
|
||||
assert!(xml.contains(r#"<w:del w:id="4" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMath>"#), "del directly encloses inline math: {xml}");
|
||||
assert!(xml.contains(r#"</w:del><m:oMath>"#), "plain equation follows closing tag without revision wrapper: {xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
assert_eq!(r.revisions, d.revisions);
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 5);
|
||||
let eq_summary: Vec<(bool, String)> = got
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Equation { display, linear, .. } => (*display, linear.clone()),
|
||||
_ => panic!("expected equation object, got {o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
eq_summary,
|
||||
vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),]
|
||||
);
|
||||
assert_eq!(got.runs, p.runs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_omath_para_revisions_read() {
|
||||
// Defensive reader check: w:ins and w:del nested inside m:oMathPara.
|
||||
let mut d = Document::new();
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
|
||||
let doc_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMathPara><w:ins w:id="1" w:author="Ada" w:date="2026-05-01T10:00:00Z"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></w:ins><w:del w:id="2" w:author="Charles" w:date="2026-05-02T10:00:00Z"><m:oMath><m:r><m:t>y-1</m:t></m:r></m:oMath></w:del></m:oMathPara></w:p></w:body></w:document>"#;
|
||||
let base = wordcraft_docx::write(&d).unwrap();
|
||||
let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes());
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
let got = ¶s(&r)[0];
|
||||
assert_eq!(got.objects.len(), 2);
|
||||
assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1"));
|
||||
assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1"));
|
||||
assert_eq!(got.runs[0].props.ins, Some(0));
|
||||
assert_eq!(got.runs[1].props.del, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_paragraph_marks_round_trip() {
|
||||
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
|
||||
|
||||
@@ -18,6 +18,25 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
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 document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
for (name, value) in [
|
||||
("_rels/.rels", root.as_bytes()),
|
||||
("word/_rels/document.xml.rels", document_rels.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/styles.xml", styles.as_bytes()),
|
||||
] {
|
||||
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
bytes.write_all(value).unwrap();
|
||||
}
|
||||
bytes.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
@@ -59,3 +78,52 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
|
||||
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
|
||||
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
use wordcraft_doc::props::ParagraphTextDirection;
|
||||
|
||||
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
|
||||
let mut body: String =
|
||||
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
|
||||
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
for (para, value) in paras.iter().zip(values) {
|
||||
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
|
||||
}
|
||||
assert_eq!(paras[values.len()].props.text_direction, None);
|
||||
let json = serde_json::to_value(¶s[0].props).unwrap();
|
||||
assert_eq!(json["textDirection"], "lrTb");
|
||||
let decoded: ParaProps = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
|
||||
|
||||
let first = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&first);
|
||||
for value in values {
|
||||
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
|
||||
}
|
||||
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
|
||||
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
|
||||
let twice = document_xml(&second);
|
||||
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
|
||||
for value in values {
|
||||
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
|
||||
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
|
||||
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
||||
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
|
||||
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
|
||||
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut styles_xml = String::new();
|
||||
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
|
||||
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
|
||||
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
|
||||
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
|
||||
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
|
||||
//! drawing is written once and filled in with either set of namespaces.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::{Document, InlineObject, PartKind};
|
||||
|
||||
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
|
||||
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
|
||||
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
const TRANSITIONAL_RELS: &str = 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>"#;
|
||||
|
||||
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
|
||||
/// text box content element.
|
||||
struct Names {
|
||||
ns: &'static str,
|
||||
rels: &'static str,
|
||||
shape: &'static str,
|
||||
group: &'static str,
|
||||
canvas: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
|
||||
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
|
||||
/// The Strict schema's own names: the text box content in `wp:` too.
|
||||
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
|
||||
/// The same drawings in a Transitional document.
|
||||
const TRANSITIONAL: Names =
|
||||
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn solid(rgb: &str) -> String {
|
||||
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
|
||||
}
|
||||
|
||||
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
|
||||
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
|
||||
let s = n.shape;
|
||||
let connector = prst.contains("Connector");
|
||||
let nv = if connector {
|
||||
format!("<{s}:cNvCnPr/>")
|
||||
} else if text.is_some() {
|
||||
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
|
||||
} else {
|
||||
format!("<{s}:cNvSpPr/>")
|
||||
};
|
||||
let fill = if connector { String::new() } else { solid(fill) };
|
||||
let line =
|
||||
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
|
||||
let txbx = match text {
|
||||
Some(t) => {
|
||||
let c = n.content;
|
||||
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
|
||||
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
|
||||
/// shape and a group, each 1 in × ½ in.
|
||||
fn package(n: &Names) -> Vec<u8> {
|
||||
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
|
||||
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
|
||||
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
|
||||
let full = (0, 0, 914_400, 457_200);
|
||||
let half = (0, 0, 457_200, 457_200);
|
||||
let g = n.group;
|
||||
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
|
||||
format!(
|
||||
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
|
||||
solid("F39C12")
|
||||
)
|
||||
};
|
||||
let group = format!(
|
||||
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
|
||||
group_xfrm(full),
|
||||
shape(n, "ellipse", half, "28B463", None),
|
||||
group_xfrm((457_200, 0, 457_200, 457_200)),
|
||||
shape(n, "triangle", half, "8E44AD", None),
|
||||
);
|
||||
let c = n.canvas;
|
||||
let canvas = format!(
|
||||
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
|
||||
solid("FDF2E9"),
|
||||
solid("A04000"),
|
||||
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
|
||||
group_xfrm((457_200, 45_720, 365_760, 365_760)),
|
||||
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
|
||||
);
|
||||
let drawings = [
|
||||
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
|
||||
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
|
||||
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
|
||||
anchor(4, 5_029_200, WPG, &group),
|
||||
anchor(5, 6_400_800, WPC, &canvas),
|
||||
];
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
|
||||
n.ns,
|
||||
drawings.concat()
|
||||
);
|
||||
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn objects(d: &Document) -> Vec<InlineObject> {
|
||||
d.body[0].as_para().unwrap().objects.clone()
|
||||
}
|
||||
|
||||
/// The text of each text box story, in reading order.
|
||||
fn box_texts(d: &Document) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
|
||||
ids.iter()
|
||||
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_read_like_their_transitional_twins() {
|
||||
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
|
||||
let want = objects(&transitional);
|
||||
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
|
||||
// nested group is flattened into it, and a canvas with its background.
|
||||
assert_eq!(want.len(), 5, "{want:#?}");
|
||||
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
|
||||
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
|
||||
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
|
||||
assert!(
|
||||
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
|
||||
"{:?}",
|
||||
want[4]
|
||||
);
|
||||
assert_eq!(box_texts(&transitional), ["Strict text box"]);
|
||||
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
|
||||
let strict = wordcraft_docx::read(&package(names)).unwrap();
|
||||
assert_eq!(objects(&strict), want, "{name}");
|
||||
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_are_saved_and_reopened() {
|
||||
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
|
||||
let saved = wordcraft_docx::write(&strict).unwrap();
|
||||
let xml = document_xml(&saved);
|
||||
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
|
||||
assert!(xml.contains(name), "{name} missing from the saved document");
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(objects(&reopened), objects(&strict));
|
||||
assert_eq!(box_texts(&reopened), ["Strict text box"]);
|
||||
}
|
||||
@@ -132,6 +132,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
|
||||
// In a group, a member's margins go with that member's text, not an empty box's before it.
|
||||
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
|
||||
// Text direction (#5193), as Word for Windows reads it: `vertical` and
|
||||
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
|
||||
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
|
||||
shape("", r#"style="layout-flow:vertical""#),
|
||||
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
|
||||
shape("", r#"style="layout-flow:vertical-ideographic""#),
|
||||
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal-ideographic""#),
|
||||
]
|
||||
.concat();
|
||||
let doc = format!(
|
||||
@@ -140,13 +149,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
let rels = br#"<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 d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
|
||||
assert_eq!(bodies.len(), 6);
|
||||
assert_eq!(bodies.len(), 12);
|
||||
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
|
||||
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
|
||||
assert_eq!(bodies[2], TextBody::default());
|
||||
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
|
||||
assert_eq!(bodies[4].inset, None);
|
||||
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
|
||||
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
|
||||
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
|
||||
// Saved and read again, they stay.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
|
||||
|
||||
@@ -228,6 +228,7 @@ fn hyperlink_field_targets_follow_the_field_code() {
|
||||
for (para, link) in [
|
||||
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
|
||||
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
|
||||
(complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")),
|
||||
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
|
||||
(simple(r#"HYPERLINK \l "Sec" \m"#), Some("#Sec")),
|
||||
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
|
||||
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
|
||||
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
|
||||
//! Fixtures are hand-written.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
/// A package whose body is `body`.
|
||||
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>"#
|
||||
);
|
||||
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn props(body: &str) -> Vec<ParaProps> {
|
||||
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_beyond_16_bits_wrap() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!(p[0].indent_left, Some(36.0));
|
||||
assert_eq!(p[1].indent_right, Some(72.0));
|
||||
// 65556 -> 20 twips; -65516 -> 20 twips.
|
||||
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
|
||||
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
|
||||
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_beyond_16_bits_wraps() {
|
||||
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
|
||||
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_range_and_unit_values_are_unchanged() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
|
||||
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
|
||||
}
|
||||
@@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() {
|
||||
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
|
||||
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
|
||||
}
|
||||
|
||||
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
|
||||
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
|
||||
#[test]
|
||||
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
|
||||
let d = read_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(paras(&d)[0].plain_text(), "a b");
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ chrono = { workspace = true, features = ["clock"] }
|
||||
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
|
||||
libc = { workspace = true }
|
||||
|
||||
# A plain fsync where a file system refuses the full flush (`sync_file`, safe_save.rs).
|
||||
# Already in Cargo.lock on Apple targets (craft-atomic), so this adds no new crate.
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
encoding_rs = { workspace = true }
|
||||
|
||||
@@ -347,19 +347,24 @@ pub(crate) struct InsertEffect {
|
||||
|
||||
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
|
||||
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
insert_text(s, text, true).map(|_| ())
|
||||
insert_text(s, text, true, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`type_text`] with the effect the insertion had.
|
||||
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
|
||||
insert_text(s, text, true)
|
||||
insert_text(s, text, true, None)
|
||||
}
|
||||
|
||||
/// Insert pasted plain text like [`type_text`], except that its paragraph separators are text
|
||||
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
|
||||
/// one would end the list).
|
||||
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
insert_text(s, text, false).map(|_| ())
|
||||
insert_text(s, text, false, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Insert typed text in a chosen language without losing caret formatting or tracked changes.
|
||||
pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>) -> Result<(), CmdError> {
|
||||
insert_text(s, text, true, language).map(|_| ())
|
||||
}
|
||||
|
||||
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
|
||||
@@ -368,7 +373,7 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
|
||||
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
|
||||
/// changed.
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>) -> Result<InsertEffect, CmdError> {
|
||||
let mut props = s.typing_props();
|
||||
if s.sel.is_collapsed() {
|
||||
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
|
||||
@@ -383,6 +388,9 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
|
||||
if !enter {
|
||||
props = props.without_symbol();
|
||||
}
|
||||
if let Some(language) = language {
|
||||
props.lang = Some(language.to_string());
|
||||
}
|
||||
let deletion = delete_selection_effect(s)?;
|
||||
// Typed text is new: never deleted, and not a formatting change of the text around it.
|
||||
props.fmt_change = None;
|
||||
|
||||
@@ -323,6 +323,7 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let st = s.sel.focus.story;
|
||||
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
|
||||
s.pending = None;
|
||||
s.column = None;
|
||||
s.column_mode = false;
|
||||
sel_result(s)
|
||||
@@ -332,6 +333,7 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
|
||||
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
|
||||
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
|
||||
s.pending = None;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -392,6 +394,7 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
|
||||
if s.doc.para_at(&f).is_some() {
|
||||
let (a, b) = unit_range(s, &f, unit);
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
s.pending = None;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -486,6 +489,9 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if count == n {
|
||||
s.sel =
|
||||
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
|
||||
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
|
||||
// replaces the selection takes its formatting.
|
||||
s.pending = None;
|
||||
return sel_result(s);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,9 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save)
|
||||
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format), "strict"?: bool (a .pdf or .png only: refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save), "strict"?: bool (as file.save)}"#).pure(),
|
||||
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
|
||||
// to undo; it marks the document as changed itself.
|
||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
|
||||
@@ -114,10 +114,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
|
||||
Ok(json!({"path": path, "markup": opts.markup}))
|
||||
})
|
||||
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
|
||||
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
|
||||
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
|
||||
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character on the page can't be drawn or a picture is missing; see file.strictReport)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.strictReport", "Strict Export Report", "File › Export", strict_report)
|
||||
.params(r#"{"markup"?: bool (as file.exportPdf), "page"?: n (1-based: check that page of the page image file.exportPng writes; omit for every page of a PDF)} → {"schema": 1, "strict": "passed" | "failed", "missing_glyphs": [{"cp": "U+2D30", "count": n}], "missing_pictures": [{"part", "source_part": the story part it is in (word/header2.xml; background, body, header, footer, text box or "" where unknown), "reason": "missing_part" | "external_target" | "link_only" | "undecodable" | "unsupported_format"}], "truncated": bool (either list was cut at 256 entries), "total_missing_glyphs": n (distinct, past the cap too), "total_missing_pictures": n (past the cap too), "glyphs_truncated": bool, "pictures_truncated": bool}"#)
|
||||
.pure(),
|
||||
// A print setting, like Word's: not part of the document, so no undo step.
|
||||
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
|
||||
@@ -253,27 +256,8 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
);
|
||||
}
|
||||
let password = p::str(v, "password");
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
|
||||
// blank document that saves back to it (#1384).
|
||||
if bytes.is_empty() {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region()).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)?
|
||||
};
|
||||
// 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")
|
||||
{
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
// An empty file opens as a new blank document that saves back to it (#1384).
|
||||
let (doc, encrypted) = read_document(s, path, v)?;
|
||||
match s.used_because() {
|
||||
None => s.set_document(doc),
|
||||
Some(why) => {
|
||||
@@ -294,6 +278,53 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)
|
||||
}
|
||||
|
||||
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
|
||||
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
|
||||
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
|
||||
/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are
|
||||
/// in the editing language.
|
||||
fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> {
|
||||
let password = p::str(v, "password");
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
if bytes.is_empty() {
|
||||
(normal_document(s), false)
|
||||
} else {
|
||||
crate::io::open_bytes_in(path, &bytes, password, &s.region()).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)?
|
||||
};
|
||||
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")
|
||||
{
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
Ok((doc, encrypted))
|
||||
}
|
||||
|
||||
/// File › New from a template (#2852): a new, untitled document with the template's content,
|
||||
/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`).
|
||||
/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank
|
||||
/// document gives way to it, as to a file `file.open` opens: that is how a template the platform
|
||||
/// hands the app starts (#3433). The template is only read; the new document has no file, so
|
||||
/// saving it asks where to write, and a protected template's password doesn't come along.
|
||||
pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let (mut doc, _) = read_document(s, path, v)?;
|
||||
doc.settings.attached_template = Some(path.to_string());
|
||||
if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() {
|
||||
s.set_document(doc);
|
||||
} else {
|
||||
s.add_document(doc);
|
||||
}
|
||||
s.dirty = true;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Close a document with no window to ask about it (agents, headless sessions).
|
||||
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = match v.get("id").filter(|x| !x.is_null()) {
|
||||
@@ -341,6 +372,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
};
|
||||
check_strict_output(p::bool(v, "strict").unwrap_or(false), &path.to_string_lossy())?;
|
||||
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
|
||||
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
|
||||
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
|
||||
@@ -422,9 +454,39 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
|
||||
comments: markup && s.prefs.markup.comments,
|
||||
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
|
||||
numeral: s.view.numeral.resolve(crate::session::system_locale()),
|
||||
strict: p::bool(v, "strict").unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// `strict: true` is for a PDF or a page image only: any other output is a parameter error.
|
||||
fn check_strict_output(strict: bool, path: &str) -> Result<(), CmdError> {
|
||||
if strict && !crate::strict::is_strict_output(path) {
|
||||
return Err(CmdError::Params("`strict` applies to a .pdf or .png output only".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The strict report of a PDF export of the document (every page), or with `page` of the page
|
||||
/// image `file.exportPng` writes. Pure: it reads the document and writes nothing.
|
||||
fn strict_report(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let opts = export_options(s, v);
|
||||
let report = match p::u64(v, "page") {
|
||||
Some(n) => {
|
||||
let page = crate::p::page_index(n);
|
||||
let l = s.export_layout_with(opts.markup);
|
||||
if l.pages.get(page).is_none() {
|
||||
return Err(CmdError::Params(format!("no page {}", page + 1)));
|
||||
}
|
||||
crate::strict::strict_report(&s.doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png)
|
||||
}
|
||||
None => {
|
||||
let out = super::references::output_fields(&s.doc);
|
||||
crate::strict::pdf_report(out.as_ref().unwrap_or(&s.doc), &opts)
|
||||
}
|
||||
};
|
||||
Ok(report.to_json())
|
||||
}
|
||||
|
||||
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// No path: the app asks where to save, as file.exportPdf does (#2313).
|
||||
let Some(path) = p::str(v, "path") else {
|
||||
@@ -432,16 +494,22 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Ok(json!({"exported": false}));
|
||||
};
|
||||
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
|
||||
let strict = p::bool(v, "strict").unwrap_or(false);
|
||||
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
|
||||
let l = s.export_layout_with(export_options(s, v).markup);
|
||||
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
|
||||
// A page image is the printed page: headers and footers at full strength. Dimming them is
|
||||
// the on-screen cue while editing the body, not part of the page.
|
||||
let mut opts = wordcraft_render::RenderOptions::default();
|
||||
opts.display.dim_header = false;
|
||||
let opts = wordcraft_render::RenderOptions { display: crate::strict::png_display(), ..Default::default() };
|
||||
if !path.to_ascii_lowercase().ends_with(".png") {
|
||||
return Err(CmdError::Params("path must end in .png".into()));
|
||||
}
|
||||
if strict {
|
||||
let report = crate::strict::strict_report(&s.doc, &l, &opts.display, Some(page..=page), wordcraft_layout::StrictOutput::Png);
|
||||
if !report.passed() {
|
||||
return Err(CmdError::Failed(report.refusal()));
|
||||
}
|
||||
}
|
||||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
|
||||
let png = img.to_png();
|
||||
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
|
||||
|
||||
@@ -198,7 +198,9 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
|
||||
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
|
||||
continue;
|
||||
}
|
||||
atoms.push(lit(c));
|
||||
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
|
||||
// codes above, and literal/regex/wildcard searches, keep exact character matching.
|
||||
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
|
||||
}
|
||||
let filler = match (o.ignore_punct, o.ignore_space) {
|
||||
(true, true) => r"[\p{P} \t\x{A0}]*",
|
||||
|
||||
@@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
|
||||
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
|
||||
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
|
||||
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
|
||||
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
|
||||
pub(crate) struct ParaPropTracker {
|
||||
before: Vec<Revision>,
|
||||
}
|
||||
|
||||
impl ParaPropTracker {
|
||||
pub(crate) fn new(s: &Session) -> Self {
|
||||
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
|
||||
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
|
||||
}
|
||||
|
||||
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
|
||||
return after.fmt_change.clone();
|
||||
}
|
||||
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
|
||||
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
|
||||
.map(|Replacement(change)| change)
|
||||
.unwrap_or_else(|| after.fmt_change.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
|
||||
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::{delete_selection, sel_result, split_para, type_text};
|
||||
use super::{delete_selection, sel_result, split_para, type_text, type_text_with_lang};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod header_footer;
|
||||
@@ -15,7 +15,9 @@ mod objects;
|
||||
|
||||
pub(crate) use header_footer::saved_copy;
|
||||
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
|
||||
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
|
||||
pub use items::{
|
||||
DropCapState, default_date_picture, drop_cap_state, format_date, format_date_at, format_date_in, format_date_parts, format_date_parts_in,
|
||||
};
|
||||
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
|
||||
use links::{bookmark, delete_bookmark, link, link_targets};
|
||||
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
|
||||
@@ -23,6 +25,7 @@ pub use objects::{base64_decode, base64_encode};
|
||||
use objects::{icon, shape, table, text_box};
|
||||
pub(crate) use objects::{image_ext, picture};
|
||||
|
||||
/// Command specifications for the Insert ribbon tab and menu.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| {
|
||||
@@ -113,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.doc.last_section.footers = Default::default();
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#),
|
||||
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool, "language"?: "en-US|pt-BR"}"#),
|
||||
CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", symbol).params(r#"{"char": string, "font"?: family (the symbol's own font)}"#),
|
||||
CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#),
|
||||
// Word's Alt+Shift+D / T / P (Control+Shift+… in Word for Mac): DATE, TIME and PAGE fields.
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
use super::PartKind;
|
||||
use super::header_footer::hf_part;
|
||||
use super::{
|
||||
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
|
||||
delete_selection, json, p, para_block, sel_result, type_text,
|
||||
delete_selection, json, p, para_block, sel_result, type_text, type_text_with_lang,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option<usize>) -> bool {
|
||||
let Some(blocks) = doc.story(story) else { return false };
|
||||
let mut seen = BTreeSet::from([story]);
|
||||
let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect();
|
||||
while let Some(block) = pending.pop() {
|
||||
let para = match block {
|
||||
Block::Para(para) => para,
|
||||
Block::Table(table) => {
|
||||
pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for object in ¶.objects {
|
||||
if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE")))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
for id in object.text_boxes() {
|
||||
if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox)
|
||||
&& seen.insert(StoryRef::Part(id))
|
||||
{
|
||||
pending.extend(part.blocks.iter().map(|b| &**b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result<Paragraph, CmdError> {
|
||||
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
para.props.align = Some(align);
|
||||
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
|
||||
if x_of_y {
|
||||
para.insert_text(0, "Page of ", &CharProps::default())?;
|
||||
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
|
||||
let end = para.len();
|
||||
para.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps { bold: Some(true), ..Default::default() },
|
||||
)?;
|
||||
} else {
|
||||
para.insert_object(0, field, &CharProps::default())?;
|
||||
}
|
||||
Ok(para)
|
||||
}
|
||||
|
||||
// Only cached display text is transferable; instructions, flags and formatting remain part of ownership.
|
||||
fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) {
|
||||
for object in &mut to.objects {
|
||||
let InlineObject::Field { instr, result, .. } = object else { continue };
|
||||
if let Some(cached) = from.objects.iter().find_map(|object| match object {
|
||||
InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result),
|
||||
_ => None,
|
||||
}) {
|
||||
result.clone_from(cached);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let position = p::str(v, "position").unwrap_or("bottom");
|
||||
@@ -22,30 +86,86 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let header = position == "top";
|
||||
let id = hf_part(s, header);
|
||||
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
|
||||
para.props.align = Some(align);
|
||||
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
|
||||
if x_of_y {
|
||||
para.insert_text(0, "Page of ", &CharProps::default())?;
|
||||
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
|
||||
let end = para.len();
|
||||
para.insert_object(
|
||||
end,
|
||||
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps { bold: Some(true), ..Default::default() },
|
||||
)?;
|
||||
} else {
|
||||
para.insert_object(0, field, &CharProps::default())?;
|
||||
let story = StoryRef::Part(id);
|
||||
let style = if header { "Header" } else { "Footer" };
|
||||
let mut para = page_number_paragraph(style, align, x_of_y)?;
|
||||
// Replace only a top-level paragraph matching our generated structure. User text,
|
||||
// field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number.
|
||||
// Cached field results and paragraph-mark formatting survive gallery changes and identical requests.
|
||||
if story_has_page_field(&s.doc, story, None) {
|
||||
let generated = s.doc.story(story).and_then(|blocks| {
|
||||
blocks.iter().enumerate().find_map(|(i, block)| {
|
||||
let Block::Para(existing) = &**block else { return None };
|
||||
let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None };
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.any(|x_of_y| {
|
||||
page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| {
|
||||
if p.objects.len() != existing.objects.len() {
|
||||
return false;
|
||||
}
|
||||
p.mark = existing.mark.clone();
|
||||
copy_page_number_results(&mut p, existing);
|
||||
*existing == p
|
||||
})
|
||||
})
|
||||
.then_some((i, existing))
|
||||
})
|
||||
});
|
||||
// More than one PAGE makes ownership ambiguous, including in tables or anchored text
|
||||
// boxes. The traversal visits each story once so cycles cannot hang insertion.
|
||||
if let Some((i, existing)) = generated
|
||||
&& !story_has_page_field(&s.doc, story, Some(i))
|
||||
{
|
||||
para.mark = existing.mark.clone();
|
||||
copy_page_number_results(&mut para, existing);
|
||||
let replacement = para_block(para);
|
||||
if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) {
|
||||
let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?;
|
||||
*block = replacement;
|
||||
}
|
||||
}
|
||||
return Ok(json!({"story": id}));
|
||||
}
|
||||
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
|
||||
let blocks = s.doc.story_mut(story)?;
|
||||
// A DOCX round trip can add paragraph-mark formatting to the otherwise untouched
|
||||
// Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line.
|
||||
if let [block] = blocks.as_slice()
|
||||
&& let Block::Para(existing) = &**block
|
||||
{
|
||||
let mut placeholder = Paragraph::new().styled(style);
|
||||
placeholder.mark = existing.mark.clone();
|
||||
if *existing == placeholder {
|
||||
para.mark = existing.mark.clone();
|
||||
blocks.clear();
|
||||
}
|
||||
}
|
||||
blocks.push(para_block(para));
|
||||
Ok(json!({"story": id}))
|
||||
}
|
||||
|
||||
/// Insert date and time at the caret or selection, formatted with a Word picture or default format.
|
||||
pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
|
||||
let text = format_date(fmt);
|
||||
let language = p::str(v, "language");
|
||||
if language.is_some_and(|l| !matches!(l, "en-US" | "pt-BR")) {
|
||||
return Err(CmdError::Params("language must be en-US or pt-BR".into()));
|
||||
}
|
||||
let mut props = s.typing_props();
|
||||
if let Some(lang) = language {
|
||||
props.lang = Some(lang.to_string());
|
||||
}
|
||||
// Resolve the same run language used by refresh_date_fields on subsequent updates.
|
||||
// Resolving language from the selection start ordered().0 is intentional for the fixed-text path
|
||||
// so the typed text takes the character/paragraph styling where the insertion begins.
|
||||
let effective_language = s.doc.para_at(&s.sel.ordered().0).and_then(|p| s.doc.styles.resolve_char(p.props.style.as_deref(), &props).lang);
|
||||
// A missing picture follows the selected or inherited language, just like a DATE field
|
||||
// without a \\@ switch; an explicitly supplied picture is never overridden.
|
||||
let fmt = p::str(v, "format").unwrap_or_else(|| default_date_picture(effective_language.as_deref()));
|
||||
if fmt.is_empty() || fmt.len() > 120 || fmt.contains('"') || fmt.contains('\\') {
|
||||
return Err(CmdError::Params("invalid date picture".into()));
|
||||
}
|
||||
let text = format_date_in(fmt, effective_language.as_deref());
|
||||
if p::bool(v, "update").unwrap_or(false) {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(
|
||||
&at,
|
||||
@@ -54,32 +174,87 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
|
||||
)?;
|
||||
s.sel = Selection::caret(end);
|
||||
} else {
|
||||
type_text(s, &text)?;
|
||||
type_text_with_lang(s, &text, language)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
|
||||
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…),
|
||||
/// in English.
|
||||
pub fn format_date(fmt: &str) -> String {
|
||||
// Word shows the computer's local date and time (#1707), not UTC's.
|
||||
format_date_in(fmt, None)
|
||||
}
|
||||
|
||||
/// Format today's date with a Word picture, month and weekday names in the language `lang` (a
|
||||
/// tag such as `pt-BR`; English when `None` or a language without names here).
|
||||
pub fn format_date_in(fmt: &str, lang: Option<&str>) -> String {
|
||||
let now = crate::cmd::now_unix();
|
||||
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
|
||||
format_date_at_in(fmt, lang, now, crate::cmd::local_offset_secs(now))
|
||||
}
|
||||
|
||||
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
|
||||
/// `offset_secs` east of UTC.
|
||||
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
|
||||
format_date_at_in(fmt, None, unix, offset_secs)
|
||||
}
|
||||
|
||||
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
|
||||
/// `offset_secs` east of UTC, with language `lang`.
|
||||
pub fn format_date_at_in(fmt: &str, lang: Option<&str>, unix: u64, offset_secs: i64) -> String {
|
||||
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
|
||||
format_date_parts(fmt, y, m as usize, d, hms, days)
|
||||
format_date_parts_in(fmt, lang, y, m as usize, d, hms, days)
|
||||
}
|
||||
|
||||
/// Month and weekday names of a language, as Word writes them in date fields: full names, the
|
||||
/// three-letter forms (`MMM`, `ddd`) being their first three letters, without a full stop.
|
||||
/// Weekdays start on Thursday (1970-01-01).
|
||||
struct DateNames {
|
||||
/// Full month names (January through December).
|
||||
months: [&'static str; 12],
|
||||
/// Weekday names starting on Thursday (1970-01-01).
|
||||
days: [&'static str; 7],
|
||||
/// The picture of a `DATE` field without `\@`.
|
||||
default: &'static str,
|
||||
}
|
||||
|
||||
const ENGLISH: DateNames = DateNames {
|
||||
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
||||
days: ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"],
|
||||
default: "M/d/yyyy",
|
||||
};
|
||||
|
||||
/// Portuguese (observed in Word for Mac with a pt-BR document): lower-case names, `MMM` = `out`,
|
||||
/// `ddd` = `qui`, and `dd/MM/yyyy` for a field without a picture.
|
||||
const PORTUGUESE: DateNames = DateNames {
|
||||
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
|
||||
days: ["quinta-feira", "sexta-feira", "sábado", "domingo", "segunda-feira", "terça-feira", "quarta-feira"],
|
||||
default: "dd/MM/yyyy",
|
||||
};
|
||||
|
||||
/// Choose month and weekday names according to the primary language subtag.
|
||||
fn date_names(lang: Option<&str>) -> &'static DateNames {
|
||||
let primary = lang.and_then(|l| l.trim().split(['-', '_']).next()).unwrap_or("");
|
||||
if primary.eq_ignore_ascii_case("pt") { &PORTUGUESE } else { &ENGLISH }
|
||||
}
|
||||
|
||||
/// The picture Word gives a `DATE` field that has no `\@` switch, in the field's language.
|
||||
pub fn default_date_picture(lang: Option<&str>) -> &'static str {
|
||||
date_names(lang).default
|
||||
}
|
||||
|
||||
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
|
||||
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
|
||||
const MONTHS: [&str; 12] =
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
|
||||
let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January");
|
||||
let day = DAYS.get(days_since.rem_euclid(7) as usize).copied().unwrap_or("Monday");
|
||||
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, hms: (u32, u32, u32), days_since: i64) -> String {
|
||||
format_date_parts_in(fmt, None, y, m, d, hms, days_since)
|
||||
}
|
||||
|
||||
/// [`format_date_parts`] with month and weekday names in `lang`. Text between single quotes is
|
||||
/// copied as it is (`'de'`), while doubled apostrophes emit one literal quote anywhere.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
|
||||
let names = date_names(lang);
|
||||
let month = names.months.get(m.saturating_sub(1)).copied().unwrap_or(names.months[0]);
|
||||
let day = names.days.get(days_since.rem_euclid(7) as usize).copied().unwrap_or(names.days[0]);
|
||||
let three = |s: &str| -> String { s.chars().take(3).collect() };
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -121,11 +296,11 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
|
||||
('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
|
||||
('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
|
||||
('M', 4..) => out.push_str(month),
|
||||
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
|
||||
('M', 3) => out.push_str(&three(month)),
|
||||
('M', 2) => out.push_str(&format!("{m:02}")),
|
||||
('M', 1) => out.push_str(&m.to_string()),
|
||||
('d' | 'D', 4..) => out.push_str(day),
|
||||
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
|
||||
('d' | 'D', 3) => out.push_str(&three(day)),
|
||||
('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
|
||||
('d' | 'D', 1) => out.push_str(&d.to_string()),
|
||||
('H', 2..) => out.push_str(&format!("{hh:02}")),
|
||||
@@ -337,3 +512,79 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
|
||||
text_font,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod date_time_locale_tests {
|
||||
use super::*;
|
||||
/// A DATE field without an explicit language initially uses the paragraph's and document's
|
||||
/// inherited pt-BR language, just as the subsequent refresh will.
|
||||
#[test]
|
||||
fn date_field_inherits_document_default_locale_on_first_render() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
std::sync::Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("pt-BR".into());
|
||||
s.run(
|
||||
"insert.dateTime",
|
||||
&json!({
|
||||
"format": "d 'de' MMMM 'de' yyyy", "update": true
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let path = s.doc.para_paths(StoryRef::Body).remove(0);
|
||||
let p = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
|
||||
let expected = format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
|
||||
assert_eq!(*result, expected);
|
||||
assert!(result.contains(" de "));
|
||||
let before = result.clone();
|
||||
crate::cmd::references::refresh_date_fields(&mut s.doc);
|
||||
let p = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
|
||||
assert_eq!(*result, before, "refresh must not switch languages");
|
||||
}
|
||||
|
||||
/// Fixed date text uses the selected pt-BR editing language, without dropping the
|
||||
/// caret's other character formatting or making the insertion refreshable.
|
||||
#[test]
|
||||
fn fixed_date_text_keeps_selected_language() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run(
|
||||
"insert.dateTime",
|
||||
&json!({
|
||||
"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": false
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let path = s.doc.para_paths(StoryRef::Body).remove(0);
|
||||
let p = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
assert_eq!(p.props_of_char(0).lang.as_deref(), Some("pt-BR"));
|
||||
assert_eq!(p.plain_text(), format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
|
||||
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { .. })));
|
||||
}
|
||||
|
||||
/// Word's doubled apostrophe escapes one literal quote both before and inside a quoted run.
|
||||
/// The existing three-character abbreviations must preserve accented Portuguese characters.
|
||||
#[test]
|
||||
fn word_date_picture_doubled_apostrophes_and_accented_abbreviations() {
|
||||
let format = |pic, day| format_date_parts_in(pic, Some("pt-BR"), 2026, 10, day, (12, 0, 0), 2);
|
||||
assert_eq!(format("d''MM", 20), "20'10");
|
||||
assert_eq!(format("d 'd''água' MMMM", 20), "20 d'água outubro");
|
||||
assert_eq!(format("ddd", 20), "sáb");
|
||||
assert_eq!(format("MMM", 20), "out");
|
||||
assert_eq!(format("MMMM", 20), "outubro");
|
||||
}
|
||||
|
||||
/// A pt-BR dynamic field keeps its result after DOCX round-trip and refresh.
|
||||
#[test]
|
||||
fn pt_br_date_time_dialog_inserts_dynamic_field() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.dateTime", &json!({"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": true})).unwrap();
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
crate::cmd::references::refresh_date_fields(&mut doc);
|
||||
let paths = doc.para_paths(StoryRef::Body);
|
||||
let p = doc.para(StoryRef::Body, &paths[0]).unwrap();
|
||||
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected field") };
|
||||
assert_eq!(result, &format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,17 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let end = s.doc.insert_text(&at, &text, &props)?;
|
||||
s.sel = Selection::caret(end);
|
||||
s.pending = Some(CharProps::default());
|
||||
} else if let Some(text) = retyped_text(s, v) {
|
||||
// Text to display changed: the selection is retyped with it, then linked (as in Word).
|
||||
crate::cmd::paste_text(s, &text)?;
|
||||
let end = s.sel.focus.clone();
|
||||
let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() };
|
||||
s.doc.format_range(&start, &end, &|c| {
|
||||
c.link = Some(url.clone());
|
||||
c.style = style.clone();
|
||||
})?;
|
||||
s.sel = Selection::caret(end);
|
||||
s.pending = Some(CharProps::default());
|
||||
} else {
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.format_range(&a, &b, &|c| {
|
||||
@@ -73,6 +84,33 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The `text` to retype a selection with before linking it: only when it differs from the selected
|
||||
/// text and both fit in one line of one paragraph without pictures or fields, outside any field's
|
||||
/// result. Otherwise (as Word shows `<<Selection in Document>>`) the selection keeps its text.
|
||||
fn retyped_text(s: &Session, v: &Value) -> Option<String> {
|
||||
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
|
||||
/// What can't stand in one line of text: breaks, and pictures or fields.
|
||||
const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ];
|
||||
let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?;
|
||||
if text.contains(NOT_ONE_LINE) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a.story != b.story || a.path != b.path {
|
||||
return None;
|
||||
}
|
||||
let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?;
|
||||
if shown.contains(NOT_ONE_LINE) || shown == text {
|
||||
return None;
|
||||
}
|
||||
// A selection in a field's result (between its markers, which it doesn't hold) is retyped by
|
||||
// the next update of the field, not here.
|
||||
if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) {
|
||||
return None;
|
||||
}
|
||||
Some(text.to_string())
|
||||
}
|
||||
|
||||
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
|
||||
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
|
||||
pub fn bookmark_name_ok(name: &str) -> bool {
|
||||
|
||||
@@ -452,6 +452,36 @@ mod tests {
|
||||
assert_eq!(numbered(&mut b), want);
|
||||
}
|
||||
|
||||
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
|
||||
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
|
||||
#[test]
|
||||
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
|
||||
let mut s = Session::new(Document::new());
|
||||
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": t})).unwrap();
|
||||
}
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]);
|
||||
s.run("list.define", &json!({"levels": levels})).unwrap();
|
||||
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
|
||||
let want: Vec<Option<String>> =
|
||||
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
|
||||
assert_eq!(page, want);
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let mut b = Session::new(back);
|
||||
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
|
||||
assert_eq!(again, want, "thaiNumbers survives .docx");
|
||||
// In parentheses too.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
|
||||
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
|
||||
}
|
||||
|
||||
/// #1793: the other heading presets of Word's List Library, level by level.
|
||||
#[test]
|
||||
fn every_heading_preset_numbers_three_heading_levels() {
|
||||
@@ -459,7 +489,8 @@ mod tests {
|
||||
("headings", [Some("1"), Some("1.1"), Some("1.1.1")]),
|
||||
("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]),
|
||||
("outlineHeadings", [Some("I."), Some("A."), Some("1.")]),
|
||||
("chapter", [Some("Chapter 1"), None, None]),
|
||||
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
|
||||
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
|
||||
] {
|
||||
let mut s = headings("Heading3");
|
||||
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
|
||||
|
||||
@@ -185,6 +185,10 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
if f.wrap == Wrap::Inline {
|
||||
f.wrap = Wrap::Square;
|
||||
}
|
||||
// A drawing anchored to a page is on this one now.
|
||||
if f.page > 0 {
|
||||
f.page = u32::try_from(page).map_or(f.page, |p| p.saturating_add(1));
|
||||
}
|
||||
f.h_rel = Anchor::Page;
|
||||
f.v_rel = Anchor::Page;
|
||||
f.h_align = None;
|
||||
@@ -199,6 +203,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
|
||||
s.touch();
|
||||
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
|
||||
edit_float(s, &at, |f| {
|
||||
// On its paragraph now: a page it was anchored to is gone, not only hidden.
|
||||
f.page = 0;
|
||||
f.h_rel = Anchor::Column;
|
||||
f.v_rel = Anchor::Paragraph;
|
||||
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user