mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:29:51 -06:00
Compare commits
264
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a34c94e4c1 | ||
|
|
a42c9eaab3 | ||
|
|
12869b8e02 | ||
|
|
8548b33be1 | ||
|
|
abd5a6e957 | ||
|
|
8dac5c7802 | ||
|
|
2922fe566b | ||
|
|
43f94a5dca | ||
|
|
d40d1b2718 | ||
|
|
05f781cfda | ||
|
|
8a97427f51 | ||
|
|
92eac2aa7c | ||
|
|
29a5abf3e7 | ||
|
|
bd9e726cda | ||
|
|
15b608660f | ||
|
|
52fbc7f068 | ||
|
|
d75364067d | ||
|
|
d102b2f08b | ||
|
|
8dc3f0cd5f | ||
|
|
f1b1077327 | ||
|
|
7b8c0ba5eb | ||
|
|
0b61f8b426 | ||
|
|
0a5d5d29f8 | ||
|
|
642f5dd6bf | ||
|
|
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 | ||
|
|
833c0154aa | ||
|
|
0a3fb5828e | ||
|
|
e344bb4243 | ||
|
|
4a507de3d6 | ||
|
|
d07f50dd8b | ||
|
|
87e08051bf | ||
|
|
6367400215 | ||
|
|
9655b2da50 | ||
|
|
66e169c343 | ||
|
|
1c6c7c732e | ||
|
|
dba2b3b5e9 | ||
|
|
beab4e8e9c | ||
|
|
c9090ba95a | ||
|
|
c606533853 | ||
|
|
4774749a05 | ||
|
|
20a10a5f33 | ||
|
|
e4310ec5c7 | ||
|
|
ecbabc327c | ||
|
|
34b4caf317 | ||
|
|
8ab285e61d | ||
|
|
490437fa87 | ||
|
|
4925e06391 | ||
|
|
5c2e5ce0d0 | ||
|
|
274d40fc4d | ||
|
|
570c39e287 | ||
|
|
f1b5f0b4de | ||
|
|
ddb1d37de3 | ||
|
|
d98a335ae8 | ||
|
|
bbc6d94dbe | ||
|
|
d0498254a2 | ||
|
|
9fd9ce54b7 | ||
|
|
d94bbc8fe0 | ||
|
|
18a26b41ce | ||
|
|
9f9190f0d7 | ||
|
|
1c7c4e66e0 | ||
|
|
2d6da7a326 | ||
|
|
30a439899b | ||
|
|
875a62043a | ||
|
|
7ca1dd1802 | ||
|
|
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 |
Generated
+6
-4
@@ -1081,7 +1081,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-pdf"
|
name = "craft-pdf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"krilla",
|
"krilla",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
@@ -1135,7 +1135,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-update"
|
name = "craft-update"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -2981,7 +2981,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "krilla"
|
name = "krilla"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
@@ -6846,13 +6846,16 @@ dependencies = [
|
|||||||
name = "wordcraft-cli"
|
name = "wordcraft-cli"
|
||||||
version = "0.15.1"
|
version = "0.15.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"image",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"wordcraft-doc",
|
"wordcraft-doc",
|
||||||
"wordcraft-engine",
|
"wordcraft-engine",
|
||||||
|
"wordcraft-fonts",
|
||||||
"wordcraft-mcp",
|
"wordcraft-mcp",
|
||||||
"wordcraft-render",
|
"wordcraft-render",
|
||||||
"wordcraft-zotero",
|
"wordcraft-zotero",
|
||||||
|
"zip 3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7562,7 +7565,6 @@ dependencies = [
|
|||||||
"nix",
|
"nix",
|
||||||
"typed-path",
|
"typed-path",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"zopfli",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+3
-3
@@ -67,9 +67,9 @@ wuff = "0.2.9"
|
|||||||
brotli-decompressor = "5"
|
brotli-decompressor = "5"
|
||||||
vello_cpu = "0.2"
|
vello_cpu = "0.2"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
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 = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
# 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 = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||||
quick-xml = "0.41"
|
quick-xml = "0.41"
|
||||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
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.
|
# 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.
|
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||||
emath = { 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" }
|
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
|
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
|
||||||
comes after it).
|
comes after it).
|
||||||
_Avoid_: Custom dictionaries
|
_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
|
```sh
|
||||||
wordcraft-cli convert report.docx report.pdf # docx, pdf, odt, rtf, html, md, tex, txt, png
|
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 text report.docx # plain text
|
||||||
wordcraft-cli inspect report.docx # structure as JSON
|
wordcraft-cli inspect report.docx # structure as JSON
|
||||||
wordcraft-cli run --template sample \
|
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
|
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>.
|
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
|
||||||
|
|
||||||
## For agents: CLI and MCP
|
## For agents: CLI and MCP
|
||||||
|
|||||||
+46
-1
File diff suppressed because one or more lines are too long
@@ -21,3 +21,8 @@ wordcraft-mcp = { workspace = true }
|
|||||||
wordcraft-zotero = { workspace = true }
|
wordcraft-zotero = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sys-locale = "0.3"
|
sys-locale = "0.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wordcraft-fonts = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
|
zip = { workspace = true }
|
||||||
|
|||||||
+173
-55
@@ -17,17 +17,23 @@ const USAGE: &str = "\
|
|||||||
wordcraft-cli — WordCraft from the command line
|
wordcraft-cli — WordCraft from the command line
|
||||||
|
|
||||||
USAGE:
|
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,
|
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;
|
||||||
an .xml file is a Word XML Document (Flat OPC), or with
|
an .xml file is a Word XML Document (Flat OPC), or with
|
||||||
--format wordml2003 a Word 2003 XML Document;
|
--format wordml2003 a Word 2003 XML Document;
|
||||||
--markup (Print Markup): comments beside each page and
|
--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 info <file> pages, words, paragraphs, properties (JSON)
|
||||||
wordcraft-cli text <file> plain text
|
wordcraft-cli text <file> plain text
|
||||||
wordcraft-cli inspect <file> document structure (JSON)
|
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 …]
|
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
|
||||||
[--save OUT] [--print] [--author NAME]
|
[--save OUT] [--print] [--author NAME]
|
||||||
run commands headlessly, then save (--author names
|
run commands headlessly, then save (--author names
|
||||||
@@ -77,6 +83,14 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
|
|||||||
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
|
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.
|
/// The error every subcommand gives when a required path is not there.
|
||||||
fn missing_argument() -> Failure {
|
fn missing_argument() -> Failure {
|
||||||
format!("missing argument\n\n{USAGE}").into()
|
format!("missing argument\n\n{USAGE}").into()
|
||||||
@@ -85,11 +99,11 @@ fn missing_argument() -> Failure {
|
|||||||
/// Open a document for a subcommand. A password-protected Word file needs its password
|
/// 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
|
/// ([`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.
|
/// `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
|
// 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).
|
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
|
||||||
let region = system_region();
|
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);
|
let mut s = session(doc);
|
||||||
s.path = Some(path.into());
|
s.path = Some(path.into());
|
||||||
if encrypted {
|
if encrypted {
|
||||||
@@ -198,7 +212,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
|
/// 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.
|
/// 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> {
|
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> {
|
fn arg_value(args: &[String], name: &str) -> Option<String> {
|
||||||
@@ -310,10 +324,12 @@ const CONVERT_OPTIONS: &[OptionSpec] = &[
|
|||||||
OptionSpec { name: "--markup", takes_value: false },
|
OptionSpec { name: "--markup", takes_value: false },
|
||||||
OptionSpec { name: "--format", takes_value: true },
|
OptionSpec { name: "--format", takes_value: true },
|
||||||
OptionSpec { name: "--update-fields", takes_value: false },
|
OptionSpec { name: "--update-fields", takes_value: false },
|
||||||
|
OptionSpec { name: "--strict", takes_value: false },
|
||||||
OptionSpec { name: "--password", takes_value: true },
|
OptionSpec { name: "--password", takes_value: true },
|
||||||
OptionSpec { name: "--password-file", takes_value: true },
|
OptionSpec { name: "--password-file", takes_value: true },
|
||||||
];
|
];
|
||||||
const RENDER_OPTIONS: &[OptionSpec] = &[
|
const RENDER_OPTIONS: &[OptionSpec] = &[
|
||||||
|
OptionSpec { name: "--strict", takes_value: false },
|
||||||
OptionSpec { name: "--page", takes_value: true },
|
OptionSpec { name: "--page", takes_value: true },
|
||||||
OptionSpec { name: "--scale", takes_value: true },
|
OptionSpec { name: "--scale", takes_value: true },
|
||||||
OptionSpec { name: "--password", takes_value: true },
|
OptionSpec { name: "--password", takes_value: true },
|
||||||
@@ -350,6 +366,9 @@ enum Failure {
|
|||||||
/// Whoever read stdout closed it (`wordcraft-cli commands --json | head`): there is nobody left
|
/// 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.
|
/// to write to, so stop quietly and exit 0 like other command-line tools.
|
||||||
StdoutClosed,
|
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 {
|
impl fmt::Display for Failure {
|
||||||
@@ -357,6 +376,7 @@ impl fmt::Display for Failure {
|
|||||||
match self {
|
match self {
|
||||||
Failure::Error(e) => f.write_str(e),
|
Failure::Error(e) => f.write_str(e),
|
||||||
Failure::StdoutClosed => f.write_str("stdout closed"),
|
Failure::StdoutClosed => f.write_str("stdout closed"),
|
||||||
|
Failure::Strict(report) => f.write_str(report),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,11 +422,80 @@ fn valid_connect_addr(addr: &str) -> bool {
|
|||||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
!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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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") {
|
||||||
|
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
|
||||||
|
}
|
||||||
|
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> {
|
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
|
||||||
match arg_value(rest, "--script") {
|
match arg_value(rest, "--script") {
|
||||||
Some(path) if !path.starts_with("--") => {
|
Some(path) if !path.starts_with("--") => {
|
||||||
let source = script_path.unwrap_or(Path::new(&path));
|
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()),
|
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
|
||||||
_ => Ok(None),
|
_ => Ok(None),
|
||||||
@@ -438,42 +527,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
return write_stdout(|o| write!(o, "{USAGE}"));
|
return write_stdout(|o| write!(o, "{USAGE}"));
|
||||||
}
|
}
|
||||||
match cmd {
|
match cmd {
|
||||||
"convert" => {
|
"convert" => run_convert(&rest, open),
|
||||||
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(())
|
|
||||||
}
|
|
||||||
"info" => {
|
"info" => {
|
||||||
validate_options("info", &rest, PASSWORD_OPTIONS)?;
|
validate_options("info", &rest, PASSWORD_OPTIONS)?;
|
||||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||||
let password = password_from(&rest)?;
|
let password = password_from(&rest)?;
|
||||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
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}"))
|
write_stdout(|o| writeln!(o, "{info}"))
|
||||||
}
|
}
|
||||||
"text" => {
|
"text" => {
|
||||||
@@ -489,7 +549,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||||
let password = password_from(&rest)?;
|
let password = password_from(&rest)?;
|
||||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
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}"))
|
write_stdout(|o| writeln!(o, "{tree}"))
|
||||||
}
|
}
|
||||||
"render" => {
|
"render" => {
|
||||||
@@ -503,7 +563,17 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
|
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
|
||||||
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
|
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
|
||||||
let mut s = open(&i, password.as_deref())?;
|
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}"))
|
write_stdout(|o| writeln!(o, "{r}"))
|
||||||
}
|
}
|
||||||
"run" => {
|
"run" => {
|
||||||
@@ -522,8 +592,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
let mut s = session(wordcraft_doc::Document::new());
|
let mut s = session(wordcraft_doc::Document::new());
|
||||||
let first = s.document_id();
|
let first = s.document_id();
|
||||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
s.run("file.new", ¶ms).map_err(engine_error)?;
|
||||||
s.close_document(first).map_err(|e| e.to_string())?;
|
s.close_document(first).map_err(engine_error)?;
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -532,7 +602,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
}
|
}
|
||||||
let print = rest.iter().any(|a| a == "--print");
|
let print = rest.iter().any(|a| a == "--print");
|
||||||
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
|
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 {
|
if print {
|
||||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||||
}
|
}
|
||||||
@@ -542,10 +612,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
if rest.get(i).map(String::as_str) == Some("--cmd") {
|
if rest.get(i).map(String::as_str) == Some("--cmd") {
|
||||||
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
|
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
|
||||||
let (id, params) = match spec.split_once('=') {
|
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!({})),
|
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 {
|
if print {
|
||||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||||
}
|
}
|
||||||
@@ -565,7 +637,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
return Err(format!("{out}: is a folder, not a file").into());
|
return Err(format!("{out}: is a folder, not a file").into());
|
||||||
}
|
}
|
||||||
if !out.to_ascii_lowercase().ends_with(".png") {
|
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 {
|
for out in outputs {
|
||||||
@@ -581,7 +653,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
} else {
|
} else {
|
||||||
s.run("file.save", &json!({"path": out}))
|
s.run("file.save", &json!({"path": out}))
|
||||||
};
|
};
|
||||||
r.map_err(|e| e.to_string())?;
|
r.map_err(engine_error)?;
|
||||||
eprintln!("wrote {out}");
|
eprintln!("wrote {out}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -680,10 +752,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
let mut s = open(file, None)?;
|
let mut s = open(file, None)?;
|
||||||
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
|
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
|
||||||
let (id, params) = match spec.split_once('=') {
|
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!({})),
|
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();
|
let mut opts = wordcraft_zotero::client::Options::default();
|
||||||
if let Some(p) = arg_value(&rest, "--port") {
|
if let Some(p) = arg_value(&rest, "--port") {
|
||||||
@@ -700,13 +774,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
|||||||
}
|
}
|
||||||
r
|
r
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(engine_error)?;
|
||||||
for a in &host.alerts {
|
for a in &host.alerts {
|
||||||
eprintln!("Zotero: {}", escape_peer(a));
|
eprintln!("Zotero: {}", escape_peer(a));
|
||||||
}
|
}
|
||||||
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
||||||
if let Some(o) = arg_value(&rest, "--save") {
|
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}");
|
eprintln!("wrote {o}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -730,22 +804,62 @@ fn clip(s: &str) -> String {
|
|||||||
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
|
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
|
||||||
/// terminal or pass for other output.
|
/// terminal or pass for other output.
|
||||||
fn escape_peer(s: &str) -> String {
|
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());
|
let mut out = String::with_capacity(s.len());
|
||||||
for c in s.chars() {
|
for c in s.chars() {
|
||||||
match c {
|
match c {
|
||||||
'\\' => out.push_str("\\\\"),
|
'\\' if backslash => out.push_str("\\\\"),
|
||||||
'\n' => out.push_str("\\n"),
|
'\n' => out.push_str("\\n"),
|
||||||
'\r' => out.push_str("\\r"),
|
'\r' => out.push_str("\\r"),
|
||||||
'\t' => out.push_str("\\t"),
|
'\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}') => {
|
c if is_unsafe_char(c) => out.extend(c.escape_unicode()),
|
||||||
out.extend(c.escape_unicode())
|
|
||||||
}
|
|
||||||
c => out.push(c),
|
c => out.push(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
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 {
|
fn parity_markdown(p: &Value) -> String {
|
||||||
let mut s = String::from(
|
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",
|
"# 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",
|
||||||
@@ -786,6 +900,10 @@ fn main() -> ExitCode {
|
|||||||
eprintln!("wordcraft-cli: {e}");
|
eprintln!("wordcraft-cli: {e}");
|
||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
|
Err(Failure::Strict(report)) => {
|
||||||
|
eprintln!("{report}");
|
||||||
|
ExitCode::from(3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -564,3 +564,19 @@ fn script_parse_resolves_paths_against_the_base() {
|
|||||||
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
assert_eq!(s.save, vec!["o.docx".to_string()]);
|
||||||
assert_eq!(s.commands.len(), 1);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ mod help;
|
|||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
mod non_utf8_args;
|
mod non_utf8_args;
|
||||||
mod picture_then_typing;
|
mod picture_then_typing;
|
||||||
|
mod strict;
|
||||||
mod text_tracked;
|
mod text_tracked;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
|
|||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
use std::time::{Duration, Instant};
|
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.
|
/// A connection that sends no authorised request in this time is closed.
|
||||||
const FIRST_REQUEST: Duration = Duration::from_secs(30);
|
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.
|
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
|
||||||
pub struct KeyFile(PathBuf);
|
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
|
/// 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).
|
/// `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>)> {
|
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)) {
|
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
|
|||||||
None
|
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 {
|
match &key_file {
|
||||||
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
|
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}"),
|
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.
|
/// 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
|
!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 (tx, rx) = channel::<ControlRequest>();
|
||||||
let key: Arc<str> = key.into();
|
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() {
|
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());
|
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
|
/// 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 Ok(read) = stream.try_clone() else { return };
|
||||||
let mut out = stream;
|
let mut out = stream;
|
||||||
// One overall deadline from accept to the first authorised request; after it, none.
|
// 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;
|
let mut authorised = false;
|
||||||
// The key an `auth` request gave this connection.
|
// The key an `auth` request gave this connection.
|
||||||
let mut conn_key: Option<String> = None;
|
let mut conn_key: Option<String> = None;
|
||||||
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
|||||||
}
|
}
|
||||||
if !authorised {
|
if !authorised {
|
||||||
authorised = true;
|
authorised = true;
|
||||||
|
admission.release();
|
||||||
reader.get_mut().set_deadline(None);
|
reader.get_mut().set_deadline(None);
|
||||||
}
|
}
|
||||||
if method == "auth" {
|
if method == "auth" {
|
||||||
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (req, rrx) = ControlRequest::new(method, params);
|
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
|
||||||
if tx.send(req).is_err() {
|
if tx.send(req).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
ctx.request_repaint();
|
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) {
|
if !reply(r) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -260,7 +350,7 @@ mod tests {
|
|||||||
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
|
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
|
||||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||||
let port = listener.local_addr().unwrap().port();
|
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>) {
|
fn boot() -> (u16, Receiver<ControlRequest>) {
|
||||||
@@ -512,6 +602,40 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
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]
|
#[test]
|
||||||
fn a_stale_key_file_is_replaced() {
|
fn a_stale_key_file_is_replaced() {
|
||||||
let dir = temp_dir("stale");
|
let dir = temp_dir("stale");
|
||||||
@@ -532,4 +656,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-16
@@ -133,6 +133,7 @@ impl eframe::App for App {
|
|||||||
if let Some(key_file) = &self.2 {
|
if let Some(key_file) = &self.2 {
|
||||||
key_file.remove();
|
key_file.remove();
|
||||||
}
|
}
|
||||||
|
self.0.start_pending_update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +193,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
|
|||||||
if let Some(key_file) = key_file {
|
if let Some(key_file) = key_file {
|
||||||
key_file.remove();
|
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(|| {
|
dispatch2::DispatchQueue::main().exec_async(|| {
|
||||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||||
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
|
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
|
||||||
@@ -299,25 +302,24 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save_prefs(app: &WordApp) {
|
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() {
|
if !prefs_enabled() {
|
||||||
return;
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if let Some(p) = prefs_path() {
|
let Some(p) = prefs_path() else { return Ok(false) };
|
||||||
if let Some(dir) = p.parent()
|
if let Some(dir) = p.parent() {
|
||||||
&& let Err(e) = std::fs::create_dir_all(dir)
|
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||||
{
|
|
||||||
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 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
|
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
|
||||||
@@ -352,6 +354,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
|||||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||||
|
save_prefs: Some(Box::new(try_save_prefs)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,3 +204,6 @@ real_name = "Helen Kwok"
|
|||||||
|
|
||||||
[people.ashokram-si]
|
[people.ashokram-si]
|
||||||
real_name = "Ashok Ram"
|
real_name = "Ashok Ram"
|
||||||
|
|
||||||
|
[people.benandez]
|
||||||
|
sync_github_name = true
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ impl Document {
|
|||||||
glossary: Vec::new(),
|
glossary: Vec::new(),
|
||||||
media_index: MediaIndex::default(),
|
media_index: MediaIndex::default(),
|
||||||
kept: Default::default(),
|
kept: Default::default(),
|
||||||
|
missing_pictures: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ impl Document {
|
|||||||
glossary,
|
glossary,
|
||||||
media_index: _,
|
media_index: _,
|
||||||
kept: _,
|
kept: _,
|
||||||
|
missing_pictures: _,
|
||||||
} = self;
|
} = self;
|
||||||
let Document {
|
let Document {
|
||||||
body: other_body,
|
body: other_body,
|
||||||
@@ -174,6 +176,7 @@ impl Document {
|
|||||||
glossary: other_glossary,
|
glossary: other_glossary,
|
||||||
media_index: _,
|
media_index: _,
|
||||||
kept: _,
|
kept: _,
|
||||||
|
missing_pictures: _,
|
||||||
} = other;
|
} = other;
|
||||||
same_blocks(body, other_body)
|
same_blocks(body, other_body)
|
||||||
&& same_parts(parts, other_parts)
|
&& same_parts(parts, other_parts)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ mod block;
|
|||||||
mod compare;
|
mod compare;
|
||||||
mod document;
|
mod document;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod missing;
|
||||||
mod parts;
|
mod parts;
|
||||||
mod position;
|
mod position;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -84,6 +85,7 @@ pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH};
|
|||||||
pub use compare::para_block;
|
pub use compare::para_block;
|
||||||
use document::MediaIndex;
|
use document::MediaIndex;
|
||||||
pub use error::{DocError, Result};
|
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 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 position::{Path, Pos, StoryRef};
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
@@ -138,6 +140,10 @@ pub struct Document {
|
|||||||
/// [`kept`]); never saved, ignored by `==`.
|
/// [`kept`]); never saved, ignored by `==`.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub kept: kept::Kept,
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,11 @@ pub struct Float {
|
|||||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||||
pub z: 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
|
/// 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
|
/// 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`].
|
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||||
|
|||||||
@@ -786,3 +786,31 @@ fn lone_table_open_markers_mixed_tables_and_paired() {
|
|||||||
assert!(res1.controls.open.is_empty());
|
assert!(res1.controls.open.is_empty());
|
||||||
assert_eq!(res1.controls.close, 0);
|
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");
|
||||||
|
}
|
||||||
|
|||||||
+10
-1
@@ -27,7 +27,8 @@ use wordcraft_doc::props::VAlign;
|
|||||||
use wordcraft_doc::section::NumFormat;
|
use wordcraft_doc::section::NumFormat;
|
||||||
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
|
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
|
||||||
use wordcraft_doc::{
|
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;
|
use crate::DocxError;
|
||||||
@@ -103,6 +104,8 @@ pub(crate) struct Reader<'p> {
|
|||||||
pub shape_ids: bool,
|
pub shape_ids: bool,
|
||||||
/// The main document part's name.
|
/// The main document part's name.
|
||||||
main: String,
|
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.
|
/// The package's content types, read when first needed.
|
||||||
content_types: Option<ContentTypes>,
|
content_types: Option<ContentTypes>,
|
||||||
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
|
/// 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(),
|
glued: HashSet::new(),
|
||||||
shape_ids: false,
|
shape_ids: false,
|
||||||
main: main.clone(),
|
main: main.clone(),
|
||||||
|
story: main.as_str().into(),
|
||||||
content_types: None,
|
content_types: None,
|
||||||
embedded: EmbeddedManifest::default(),
|
embedded: EmbeddedManifest::default(),
|
||||||
txbx_links: Vec::new(),
|
txbx_links: Vec::new(),
|
||||||
@@ -190,6 +194,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
|||||||
group_fill: None,
|
group_fill: None,
|
||||||
chunks,
|
chunks,
|
||||||
};
|
};
|
||||||
|
r.doc.missing_pictures.main = main.clone();
|
||||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||||
r.pc.minor_font = r.doc.settings.minor_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());
|
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 ex = part(rt::COMMENTS_EX);
|
||||||
let ids = part(rt::COMMENTS_IDS);
|
let ids = part(rt::COMMENTS_IDS);
|
||||||
let cex = part(rt::COMMENTS_EXTENSIBLE);
|
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()));
|
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
|
||||||
}
|
}
|
||||||
if let Some(p) = part(rt::FOOTNOTES) {
|
if let Some(p) = part(rt::FOOTNOTES) {
|
||||||
|
r.story = p.as_str().into();
|
||||||
lenient("footnotes", r.read_notes(&p, true));
|
lenient("footnotes", r.read_notes(&p, true));
|
||||||
}
|
}
|
||||||
if let Some(p) = part(rt::ENDNOTES) {
|
if let Some(p) = part(rt::ENDNOTES) {
|
||||||
|
r.story = p.as_str().into();
|
||||||
lenient("endnotes", r.read_notes(&p, false));
|
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
|
// 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
|
// 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.
|
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||||
|
|||||||
@@ -80,6 +80,21 @@ impl Reader<'_> {
|
|||||||
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
|
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
|
||||||
// is their own already).
|
// is their own already).
|
||||||
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
|
let 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);
|
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
|
||||||
// Bookmarks and comment marks read before the chunk stay before it.
|
// Bookmarks and comment marks read before the chunk stay before it.
|
||||||
self.flush_pending_before(sc, out, &mut blocks);
|
self.flush_pending_before(sc, out, &mut blocks);
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ impl Reader<'_> {
|
|||||||
// Without content types (a converted package) the root element alone decides.
|
// 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)) {
|
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);
|
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);
|
entries = self.doc_parts(&root, &rels);
|
||||||
|
self.story = outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.doc.glossary = entries;
|
self.doc.glossary = entries;
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ impl Reader<'_> {
|
|||||||
// Reserve the id first: the note's own reference mark points at it.
|
// 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 (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
|
||||||
let id = self.doc.add_part(pk, Blocks::new());
|
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 sc = StoryCtx::default();
|
||||||
let mut blocks = Blocks::new();
|
let mut blocks = Blocks::new();
|
||||||
self.current_note = Some((kind, id));
|
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.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
|
||||||
self.flush_pending(&mut sc, &mut blocks);
|
self.flush_pending(&mut sc, &mut blocks);
|
||||||
let part = self.doc.add_part(PartKind::Comment, 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")) {
|
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
|
||||||
by_para.insert(pid.to_string(), id);
|
by_para.insert(pid.to_string(), id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::{
|
use super::{
|
||||||
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
|
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, MissingPicture,
|
||||||
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
|
MissingPictureReason, PartKind, Reader, Rels, Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl Reader<'_> {
|
impl Reader<'_> {
|
||||||
@@ -91,14 +91,22 @@ impl Reader<'_> {
|
|||||||
|
|
||||||
/// Media key for an image relationship (loads the bytes once).
|
/// Media key for an image relationship (loads the bytes once).
|
||||||
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
|
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 {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
if let Some(k) = self.media_by_path.get(&rel.target) {
|
if let Some(k) = self.media_by_path.get(&rel.target) {
|
||||||
return Some(k.clone());
|
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 =
|
let base: String =
|
||||||
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
|
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 };
|
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
|
||||||
@@ -112,10 +120,18 @@ impl Reader<'_> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
self.doc.media.insert(key.clone(), bytes.clone());
|
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());
|
self.media_by_path.insert(rel.target.clone(), key.clone());
|
||||||
Some(key)
|
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).
|
/// 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>> {
|
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
|
||||||
if let Some(p) = self.graphic_parts.get(path) {
|
if let Some(p) = self.graphic_parts.get(path) {
|
||||||
@@ -137,6 +153,8 @@ impl Reader<'_> {
|
|||||||
let kind = if footer { PartKind::Footer } else { PartKind::Header };
|
let kind = if footer { PartKind::Footer } else { PartKind::Header };
|
||||||
let id = self.doc.add_part(kind, Blocks::new());
|
let id = self.doc.add_part(kind, Blocks::new());
|
||||||
self.hf_by_path.insert(path.to_string(), id);
|
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) {
|
let root = match self.xml(path) {
|
||||||
Ok(Some(r)) => r,
|
Ok(Some(r)) => r,
|
||||||
_ => return Some(id),
|
_ => return Some(id),
|
||||||
@@ -146,7 +164,9 @@ impl Reader<'_> {
|
|||||||
sc.in_hf = true;
|
sc.in_hf = true;
|
||||||
let mut blocks = Blocks::new();
|
let mut blocks = Blocks::new();
|
||||||
self.hf_depth += 1;
|
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.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
|
||||||
|
self.story = outer;
|
||||||
self.hf_depth = self.hf_depth.saturating_sub(1);
|
self.hf_depth = self.hf_depth.saturating_sub(1);
|
||||||
self.flush_pending(&mut sc, &mut blocks);
|
self.flush_pending(&mut sc, &mut blocks);
|
||||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||||
|
|||||||
@@ -279,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> {
|
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")?;
|
let blip = pic.find("a:blip")?;
|
||||||
float.set_spin(xfrm_spin(pic.find("pic:spPr")));
|
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];
|
let mut crop = [0.0f32; 4];
|
||||||
if let Some(sr) = pic.find("a:srcRect") {
|
if let Some(sr) = pic.find("a:srcRect") {
|
||||||
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
|
||||||
|
|||||||
@@ -131,7 +131,14 @@ impl Reader<'_> {
|
|||||||
None => vml_float(shape, style, sc.in_hf),
|
None => vml_float(shape, style, sc.in_hf),
|
||||||
};
|
};
|
||||||
if let Some(img) = shape.find("v:imagedata") {
|
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 media = self.media_for(rels, id)?;
|
||||||
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
|
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).
|
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
|
||||||
|
|||||||
@@ -372,3 +372,74 @@ fn hostile_chunks_never_panic() {
|
|||||||
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
|
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
|
||||||
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -560,3 +560,23 @@ fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
|
|||||||
let again = wordcraft_docx::read(&saved).unwrap();
|
let again = wordcraft_docx::read(&saved).unwrap();
|
||||||
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ mod heading_levels;
|
|||||||
mod legacy_forms;
|
mod legacy_forms;
|
||||||
mod ligatures;
|
mod ligatures;
|
||||||
mod malformed;
|
mod malformed;
|
||||||
|
mod missing_pictures;
|
||||||
mod nested_block_controls;
|
mod nested_block_controls;
|
||||||
mod note_comments;
|
mod note_comments;
|
||||||
mod passthrough;
|
mod passthrough;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -94,9 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
.pure(),
|
.pure(),
|
||||||
CommandSpec::new("file.save", "Save", "File", save)
|
CommandSpec::new("file.save", "Save", "File", save)
|
||||||
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
|
.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(),
|
.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
|
// 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.
|
// to undo; it marks the document as changed itself.
|
||||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
|
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)?;
|
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}))
|
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(),
|
.pure(),
|
||||||
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
|
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(),
|
.pure(),
|
||||||
// A print setting, like Word's: not part of the document, so no undo step.
|
// 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| {
|
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
|
||||||
@@ -369,6 +372,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
|||||||
return Ok(json!({"saved": false}));
|
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()) {
|
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
|
||||||
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
|
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()));
|
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
|
||||||
@@ -450,9 +454,39 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
|
|||||||
comments: markup && s.prefs.markup.comments,
|
comments: markup && s.prefs.markup.comments,
|
||||||
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
|
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
|
||||||
numeral: s.view.numeral.resolve(crate::session::system_locale()),
|
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 {
|
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
// No path: the app asks where to save, as file.exportPdf does (#2313).
|
// No path: the app asks where to save, as file.exportPdf does (#2313).
|
||||||
let Some(path) = p::str(v, "path") else {
|
let Some(path) = p::str(v, "path") else {
|
||||||
@@ -460,16 +494,22 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
|||||||
return Ok(json!({"exported": false}));
|
return Ok(json!({"exported": false}));
|
||||||
};
|
};
|
||||||
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
|
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 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 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)))?;
|
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
|
// 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.
|
// the on-screen cue while editing the body, not part of the page.
|
||||||
let mut opts = wordcraft_render::RenderOptions::default();
|
let opts = wordcraft_render::RenderOptions { display: crate::strict::png_display(), ..Default::default() };
|
||||||
opts.display.dim_header = false;
|
|
||||||
if !path.to_ascii_lowercase().ends_with(".png") {
|
if !path.to_ascii_lowercase().ends_with(".png") {
|
||||||
return Err(CmdError::Params("path must end in .png".into()));
|
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 img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
|
||||||
let png = img.to_png();
|
let png = img.to_png();
|
||||||
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
|
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// 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
|
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
|
||||||
|
|||||||
@@ -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 {
|
if f.wrap == Wrap::Inline {
|
||||||
f.wrap = Wrap::Square;
|
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.h_rel = Anchor::Page;
|
||||||
f.v_rel = Anchor::Page;
|
f.v_rel = Anchor::Page;
|
||||||
f.h_align = None;
|
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();
|
s.touch();
|
||||||
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
|
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| {
|
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.h_rel = Anchor::Column;
|
||||||
f.v_rel = Anchor::Paragraph;
|
f.v_rel = Anchor::Paragraph;
|
||||||
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
|
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod lists;
|
|||||||
mod outline;
|
mod outline;
|
||||||
mod styles;
|
mod styles;
|
||||||
mod tabs_borders;
|
mod tabs_borders;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
|
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
|
||||||
pub use format::{cur as selection_para, fmt, indent, outdent};
|
pub use format::{cur as selection_para, fmt, indent, outdent};
|
||||||
@@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort};
|
|||||||
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
|
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
|
||||||
pub use tabs_borders::border_sides;
|
pub use tabs_borders::border_sides;
|
||||||
use tabs_borders::{borders, tabs};
|
use tabs_borders::{borders, tabs};
|
||||||
|
use tools::{
|
||||||
|
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
|
||||||
|
trim_leading,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn specs() -> Vec<CommandSpec> {
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
vec![
|
vec![
|
||||||
@@ -270,6 +275,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
}
|
}
|
||||||
sel_result(s)
|
sel_result(s)
|
||||||
}),
|
}),
|
||||||
|
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
|
||||||
|
// selection, or the whole document with `scope: "all"`. One undo step per command.
|
||||||
|
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
|
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
|
||||||
|
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
|
||||||
|
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
|
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
|
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
|
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
|
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
|
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
|
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
|
||||||
|
.params(r#"{"scope"?: "selection|all"}"#),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
|
||||||
|
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
|
||||||
|
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use wordcraft_doc::para::LINE_BREAK;
|
||||||
|
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
|
||||||
|
|
||||||
|
use super::{CmdError, CmdResult, Session, p, sel_result};
|
||||||
|
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
|
||||||
|
|
||||||
|
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
|
||||||
|
/// command can add, stay bounded whatever the document says.
|
||||||
|
const MAX_TOOL_PARAS: usize = 10_000;
|
||||||
|
|
||||||
|
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
|
||||||
|
const MAX_INDENT_SPACES: usize = 1_000;
|
||||||
|
|
||||||
|
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
|
||||||
|
/// `all` selects the whole document first, so the command, its undo step and the result all
|
||||||
|
/// follow the full selection like any other command.
|
||||||
|
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
|
||||||
|
match p::str(v, "scope").unwrap_or("selection") {
|
||||||
|
"selection" => Ok(()),
|
||||||
|
"all" => {
|
||||||
|
s.run("select.all", &json!({"whole": true}))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
|
||||||
|
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
|
||||||
|
/// selection offsets in a selected paragraph.
|
||||||
|
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
|
||||||
|
let ranges: Vec<(Pos, Pos)> = paths
|
||||||
|
.iter()
|
||||||
|
.filter_map(|path| {
|
||||||
|
let len = s.doc.para(story, path)?.len();
|
||||||
|
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (a, b) in ranges {
|
||||||
|
crate::cmd::controls::check_edit(s, &a, &b)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paragraphs a command runs over, in document order, and the story they are in.
|
||||||
|
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
|
||||||
|
let (a, b) = s.sel.ordered();
|
||||||
|
let paths = s.doc.para_paths_in(&a, &b);
|
||||||
|
if paths.is_empty() {
|
||||||
|
return Err(CmdError::Failed("no paragraphs selected".into()));
|
||||||
|
}
|
||||||
|
if paths.len() > MAX_TOOL_PARAS {
|
||||||
|
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
|
||||||
|
}
|
||||||
|
Ok((a.story, paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
|
||||||
|
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
|
||||||
|
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
|
||||||
|
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
|
||||||
|
let (a, b) = (a.min(b).min(len), b.min(len));
|
||||||
|
if a >= b {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let at = |off: usize| Pos { story, path: path.clone(), off };
|
||||||
|
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
|
||||||
|
if s.doc.settings.track_changes {
|
||||||
|
track_delete(s, &at(a), &at(b))?;
|
||||||
|
} else {
|
||||||
|
s.doc.delete_range(&at(a), &at(b))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
|
||||||
|
/// last first so the earlier offsets stay valid.
|
||||||
|
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
|
||||||
|
ranges.sort();
|
||||||
|
let mut merged: Vec<(usize, usize)> = Vec::new();
|
||||||
|
for (a, b) in ranges {
|
||||||
|
match merged.last_mut() {
|
||||||
|
Some(last) if a <= last.1 => last.1 = last.1.max(b),
|
||||||
|
_ => merged.push((a, b)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (a, b) in merged.into_iter().rev() {
|
||||||
|
delete_in_para(s, story, path, a, b)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
|
||||||
|
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
|
||||||
|
/// count goes through this.
|
||||||
|
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||||
|
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
|
||||||
|
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
|
||||||
|
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
|
||||||
|
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
|
||||||
|
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
|
||||||
|
if breaks > MAX_TOOL_PARAS {
|
||||||
|
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
|
||||||
|
}
|
||||||
|
for path in paths.iter().rev() {
|
||||||
|
let Some(para) = s.doc.para(story, path) else { continue };
|
||||||
|
for (i, _) in break_ranges(para).into_iter().rev() {
|
||||||
|
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
|
||||||
|
// The break stays at the head of the new paragraph; the paragraph break replaces it.
|
||||||
|
delete_in_para(s, story, &new.path, 0, 1)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
|
||||||
|
/// adds one blank paragraph after the last selected paragraph too.
|
||||||
|
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
let after = p::bool(v, "after").unwrap_or(false);
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
let count = paths.len().saturating_sub(1) + usize::from(after);
|
||||||
|
if count > MAX_TOOL_PARAS {
|
||||||
|
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
|
||||||
|
}
|
||||||
|
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
|
||||||
|
if after {
|
||||||
|
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
|
||||||
|
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
|
||||||
|
split_para(s, &Pos { story, path: last.clone(), off: len })?;
|
||||||
|
}
|
||||||
|
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
|
||||||
|
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
|
||||||
|
split_para(s, &Pos { story, path: path.clone(), off: len })?;
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
|
||||||
|
/// blank line between them.
|
||||||
|
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.removeBlank`: delete every empty paragraph in the selection.
|
||||||
|
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
// Last first: removing a block leaves the paths before it as they are.
|
||||||
|
for path in paths.iter().rev() {
|
||||||
|
let Some(para) = s.doc.para(story, path) else { continue };
|
||||||
|
// A paragraph must be empty, carry no section break and have a paragraph after it in
|
||||||
|
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
|
||||||
|
// before a table can't be joined across it.
|
||||||
|
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let next = path.with_last(path.last() + 1);
|
||||||
|
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
|
||||||
|
crate::cmd::controls::check_delete(s, &a, &b)?;
|
||||||
|
if s.doc.settings.track_changes {
|
||||||
|
track_delete(s, &a, &b)?;
|
||||||
|
} else {
|
||||||
|
s.doc.remove_block(story, path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
|
||||||
|
/// `trimLeadingSpaces` deletes).
|
||||||
|
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
|
||||||
|
para.text.chars().take_while(|c| *c == ' ').count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
|
||||||
|
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
|
||||||
|
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
|
||||||
|
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
for path in paths {
|
||||||
|
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||||
|
let n = leading_spaces(para);
|
||||||
|
apply_deletions(s, story, &path, vec![(0, n)])?;
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
|
||||||
|
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
|
||||||
|
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
|
||||||
|
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
for path in paths {
|
||||||
|
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||||
|
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
|
||||||
|
// paragraph's start stays; a run inside the text collapses to its last space, and one
|
||||||
|
// that reaches the paragraph's end to its first.
|
||||||
|
let mut runs = Vec::new();
|
||||||
|
let mut run: Vec<usize> = Vec::new();
|
||||||
|
let mut leading = true;
|
||||||
|
for (i, c) in live_chars(para) {
|
||||||
|
if c == ' ' {
|
||||||
|
run.push(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !leading && run.len() > 1 {
|
||||||
|
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
|
||||||
|
}
|
||||||
|
run.clear();
|
||||||
|
leading = false;
|
||||||
|
}
|
||||||
|
if !leading && run.len() > 1 {
|
||||||
|
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
|
||||||
|
}
|
||||||
|
apply_deletions(s, story, &path, runs)?;
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
|
||||||
|
/// per space (Word's point-based indent from typing spaces), and are deleted.
|
||||||
|
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||||
|
for path in paths {
|
||||||
|
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||||
|
let live = live_leading_spaces(para);
|
||||||
|
let spaces = live.len();
|
||||||
|
if spaces == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
|
||||||
|
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
|
||||||
|
if represented == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let before = para.props.clone();
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.indent_first = Some(current + represented as f32 * 72.0);
|
||||||
|
after.cancel_char_indents(false, false, true);
|
||||||
|
after.fmt_change = tracker.changed(s, &before, &after);
|
||||||
|
{
|
||||||
|
let p = s.doc.para_mut(story, &path)?;
|
||||||
|
p.props = after;
|
||||||
|
p.touch();
|
||||||
|
}
|
||||||
|
// A space is one byte; deleted spaces in between stay as they are.
|
||||||
|
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
|
||||||
|
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
|
||||||
|
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
|
||||||
|
for path in paths {
|
||||||
|
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||||
|
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
|
||||||
|
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
|
||||||
|
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
|
||||||
|
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
|
||||||
|
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
|
||||||
|
props.fmt_change = None;
|
||||||
|
props.del = None;
|
||||||
|
if s.doc.settings.track_changes && n > 0 {
|
||||||
|
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
||||||
|
} else {
|
||||||
|
props.ins = None;
|
||||||
|
}
|
||||||
|
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
|
||||||
|
let mut after = before.clone();
|
||||||
|
after.indent_first = Some(0.0);
|
||||||
|
after.indent_left = Some(0.0);
|
||||||
|
after.cancel_char_indents(true, false, true);
|
||||||
|
after.fmt_change = tracker.changed(s, &before, &after);
|
||||||
|
let p = s.doc.para_mut(story, &path)?;
|
||||||
|
if n > 0 {
|
||||||
|
p.insert_text(0, &" ".repeat(n), &props)?;
|
||||||
|
}
|
||||||
|
p.props = after;
|
||||||
|
p.touch();
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
|
||||||
|
/// join into one paragraph.
|
||||||
|
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
scope_all(s, v)?;
|
||||||
|
let (story, paths) = targets(s)?;
|
||||||
|
check_target_edits(s, story, &paths)?;
|
||||||
|
for path in paths {
|
||||||
|
let Some(para) = s.doc.para(story, &path) else { continue };
|
||||||
|
apply_deletions(s, story, &path, break_ranges(para))?;
|
||||||
|
}
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ mod enter_tab;
|
|||||||
mod unicode;
|
mod unicode;
|
||||||
|
|
||||||
use autoformat::insert;
|
use autoformat::insert;
|
||||||
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
|
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
|
||||||
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
|
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
|
||||||
use enter_tab::{back_tab, new_paragraph, tab};
|
use enter_tab::{back_tab, new_paragraph, tab};
|
||||||
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
|
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
|
||||||
@@ -36,6 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
|
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
|
||||||
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
|
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
|
||||||
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
|
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
|
||||||
|
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
|
||||||
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
|
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
|
||||||
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
|
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
|
||||||
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
|
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
|
||||||
|
|||||||
@@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
|
|||||||
crate::cmd::controls::restore_placeholder(s)?;
|
crate::cmd::controls::restore_placeholder(s)?;
|
||||||
sel_result(s)
|
sel_result(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
|
||||||
|
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
|
||||||
|
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
if !s.sel.is_collapsed() {
|
||||||
|
return backspace(s, v);
|
||||||
|
}
|
||||||
|
let mut f = s.sel.focus.clone();
|
||||||
|
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||||
|
f.off = para.clamp(f.off);
|
||||||
|
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
|
||||||
|
let start = para.sentence_at(previous).0;
|
||||||
|
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
|
||||||
|
delete_selection(s)?;
|
||||||
|
crate::cmd::controls::restore_placeholder(s)?;
|
||||||
|
sel_result(s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
|
|||||||
}
|
}
|
||||||
Ok(Value::Object(out))
|
Ok(Value::Object(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
|
||||||
|
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
|
||||||
|
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
|
||||||
|
let Some(values) = v.get("values") else {
|
||||||
|
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
|
||||||
|
};
|
||||||
|
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
|
||||||
|
if list.is_empty() || list.len() > 1000 {
|
||||||
|
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
|
||||||
|
}
|
||||||
|
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
|
||||||
|
for (r, row) in list.iter().enumerate() {
|
||||||
|
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
|
||||||
|
if cells.is_empty() || cells.len() > 63 {
|
||||||
|
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
|
||||||
|
}
|
||||||
|
if rows.first().is_some_and(|f| f.len() != cells.len()) {
|
||||||
|
return Err(CmdError::Params(format!(
|
||||||
|
"`values` row {} has {} cells, row 1 has {}",
|
||||||
|
r + 1,
|
||||||
|
cells.len(),
|
||||||
|
rows.first().map_or(0, Vec::len)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(cells.len());
|
||||||
|
for cell in cells {
|
||||||
|
out.push(match cell {
|
||||||
|
Value::String(x) => x.clone(),
|
||||||
|
Value::Number(x) => x.to_string(),
|
||||||
|
Value::Bool(x) => x.to_string(),
|
||||||
|
Value::Null => String::new(),
|
||||||
|
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.push(out);
|
||||||
|
}
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::actions::{
|
use super::actions::{
|
||||||
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
||||||
|
spreadsheet_rows,
|
||||||
};
|
};
|
||||||
use super::autocorrect::autocorrect_options;
|
use super::autocorrect::autocorrect_options;
|
||||||
use super::compare::{COMPARE_PARAMS, compare};
|
use super::compare::{COMPARE_PARAMS, compare};
|
||||||
@@ -213,10 +214,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
})
|
})
|
||||||
.params(r#"{"path": string}"#),
|
.params(r#"{"path": string}"#),
|
||||||
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
|
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
|
||||||
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
|
let rows = spreadsheet_rows(v)?;
|
||||||
let rows = crate::cmd::mailings::parse_csv(csv);
|
|
||||||
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
|
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
|
||||||
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
|
let style = p::str(v, "style").unwrap_or("GridTable1Light");
|
||||||
|
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
|
||||||
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
|
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
|
||||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||||
for (r, row) in rows.iter().enumerate() {
|
for (r, row) in rows.iter().enumerate() {
|
||||||
@@ -226,9 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p::bool(v, "header").unwrap_or(false)
|
||||||
|
&& let Some(first) = t.rows.first_mut()
|
||||||
|
{
|
||||||
|
first.props.header = true;
|
||||||
|
}
|
||||||
sel_result(s)
|
sel_result(s)
|
||||||
})
|
})
|
||||||
.params(r#"{"csv"?: string}"#),
|
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
|
||||||
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
|
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
|
||||||
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
|
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
|
||||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||||
|
|||||||
@@ -255,6 +255,59 @@ fn proofing_language_options() {
|
|||||||
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
|
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
|
||||||
|
#[test]
|
||||||
|
fn spreadsheet_takes_values_header_and_style() {
|
||||||
|
let mut s = Session::new(Document::from_text(""));
|
||||||
|
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
|
||||||
|
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
|
||||||
|
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
|
||||||
|
assert!(t.rows[0].props.header && !t.rows[1].props.header);
|
||||||
|
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
|
||||||
|
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
|
||||||
|
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
|
||||||
|
#[test]
|
||||||
|
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
|
||||||
|
let mut s = Session::new(Document::from_text("keep"));
|
||||||
|
let depth = s.undo_depth();
|
||||||
|
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
|
||||||
|
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
|
||||||
|
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
|
||||||
|
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
|
||||||
|
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
|
||||||
|
}
|
||||||
|
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
|
||||||
|
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
|
||||||
|
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
|
||||||
|
#[test]
|
||||||
|
fn spreadsheet_rejects_hostile_values() {
|
||||||
|
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
|
||||||
|
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
|
||||||
|
for bad in [
|
||||||
|
json!("x"),
|
||||||
|
json!(5),
|
||||||
|
json!([]),
|
||||||
|
json!(["a"]),
|
||||||
|
json!([["a", "b"], ["c"]]),
|
||||||
|
json!([wide]),
|
||||||
|
json!(many),
|
||||||
|
json!([[{"a": 1}]]),
|
||||||
|
json!([[[1]]]),
|
||||||
|
json!([[]]),
|
||||||
|
] {
|
||||||
|
let mut s = Session::new(Document::from_text("keep"));
|
||||||
|
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
|
||||||
|
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
|
||||||
|
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
|
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
|
||||||
/// document that tracks changes the text would go with no revision to show for it, and the
|
/// document that tracks changes the text would go with no revision to show for it, and the
|
||||||
/// revision trail would be broken behind the user's back. The removal is refused instead,
|
/// revision trail would be broken behind the user's back. The removal is refused instead,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! View tab and status bar: views, zoom, show/hide, panes.
|
//! View tab and status bar: views, zoom, show/hide, panes.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use wordcraft_doc::{Zoom, ZoomFit};
|
use wordcraft_doc::{Zoom, ZoomFit};
|
||||||
use wordcraft_layout::{NumeralMode, ViewMode};
|
use wordcraft_layout::{NumeralMode, ViewMode};
|
||||||
@@ -40,6 +41,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
|
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
|
||||||
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
|
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
|
||||||
.pure(),
|
.pure(),
|
||||||
|
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
|
||||||
|
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
|
||||||
|
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
|
||||||
|
s.prefs.paper_color = color;
|
||||||
|
if color != PaperColor::Document {
|
||||||
|
s.view.dark_mode = false;
|
||||||
|
}
|
||||||
|
Ok(json!({"paperColor": color}))
|
||||||
|
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
|
||||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||||
.params(r#"{"value"?: bool}"#)
|
.params(r#"{"value"?: bool}"#)
|
||||||
@@ -141,6 +151,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
})
|
})
|
||||||
.key("Mod+Alt+S")
|
.key("Mod+Alt+S")
|
||||||
.pure(),
|
.pure(),
|
||||||
|
// View › Window across WordCraft windows (#322): the desktop app's native windows
|
||||||
|
// perform these (see `Windows`), elsewhere they are disabled.
|
||||||
|
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
|
||||||
|
s.ui_requests.push(json!({"windows": "arrange"}));
|
||||||
|
Ok(json!({"windows": s.windows.others.len() + 1}))
|
||||||
|
})
|
||||||
|
.when(windows_available)
|
||||||
|
.pure(),
|
||||||
|
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
|
||||||
|
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
|
||||||
|
.when(|s| match windows_available(s) {
|
||||||
|
None if s.windows.side_by_side.is_none() => has_other_window(s),
|
||||||
|
r => r,
|
||||||
|
})
|
||||||
|
.pure(),
|
||||||
|
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
|
||||||
|
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
|
||||||
|
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
|
||||||
|
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
|
||||||
|
Ok(json!({"value": s.windows.sync_scroll}))
|
||||||
|
})
|
||||||
|
.params(r#"{"value"?: bool}"#)
|
||||||
|
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
|
||||||
|
.pure(),
|
||||||
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
|
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
|
||||||
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
|
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
|
||||||
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
|
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
|
||||||
@@ -170,6 +204,83 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
|
||||||
|
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
|
||||||
|
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
|
||||||
|
/// stay empty and the window commands are disabled.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
pub struct Windows {
|
||||||
|
/// The host can list and move its windows (the desktop app).
|
||||||
|
pub available: bool,
|
||||||
|
/// The other open windows, by id.
|
||||||
|
pub others: Vec<OtherWindow>,
|
||||||
|
/// View Side by Side is on, with this window.
|
||||||
|
pub side_by_side: Option<u64>,
|
||||||
|
/// Synchronous Scrolling (only while Side by Side is on).
|
||||||
|
pub sync_scroll: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another open WordCraft window.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct OtherWindow {
|
||||||
|
pub id: u64,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn windows_available(s: &Session) -> Option<&'static str> {
|
||||||
|
if s.windows.available { None } else { Some("only the desktop app has several windows") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_other_window(s: &Session) -> Option<&'static str> {
|
||||||
|
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window a command means: `window`, or the only other one.
|
||||||
|
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
|
||||||
|
let others = &s.windows.others;
|
||||||
|
match p::u64(v, "window") {
|
||||||
|
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
|
||||||
|
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
|
||||||
|
None => match others.as_slice() {
|
||||||
|
[only] => Ok(only.id),
|
||||||
|
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
|
||||||
|
several => {
|
||||||
|
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
|
||||||
|
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
|
||||||
|
/// off.
|
||||||
|
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
|
||||||
|
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
|
||||||
|
if !on {
|
||||||
|
if let Some(id) = s.windows.side_by_side.take() {
|
||||||
|
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
|
||||||
|
}
|
||||||
|
s.windows.sync_scroll = false;
|
||||||
|
return Ok(json!({"value": false}));
|
||||||
|
}
|
||||||
|
let id = match (s.windows.side_by_side, v.get("window")) {
|
||||||
|
(Some(id), None) => id,
|
||||||
|
_ => pick_window(s, v)?,
|
||||||
|
};
|
||||||
|
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
|
||||||
|
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
|
||||||
|
}
|
||||||
|
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
|
||||||
|
// again; a new pairing starts with scrolling on.
|
||||||
|
if s.windows.side_by_side != Some(id) {
|
||||||
|
s.windows.side_by_side = Some(id);
|
||||||
|
s.windows.sync_scroll = true;
|
||||||
|
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
|
||||||
|
}
|
||||||
|
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
|
||||||
|
}
|
||||||
|
|
||||||
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
|
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
|
||||||
fn full_screen_available(_: &Session) -> Option<&'static str> {
|
fn full_screen_available(_: &Session) -> Option<&'static str> {
|
||||||
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
|
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
|
||||||
@@ -234,6 +345,40 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
|||||||
Ok(json!({"spacing": spacing}))
|
Ok(json!({"spacing": spacing}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Screen-only paper presets, saved with user preferences, never in the document.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum PaperColor {
|
||||||
|
#[default]
|
||||||
|
Document,
|
||||||
|
Green,
|
||||||
|
Cream,
|
||||||
|
Gray,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaperColor {
|
||||||
|
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Document => "Document background",
|
||||||
|
Self::Green => "Soft green",
|
||||||
|
Self::Cream => "Warm cream",
|
||||||
|
Self::Gray => "Soft gray",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn color(self) -> wordcraft_doc::props::Rgb {
|
||||||
|
use wordcraft_doc::props::Rgb;
|
||||||
|
match self {
|
||||||
|
Self::Document => Rgb::WHITE,
|
||||||
|
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
|
||||||
|
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
|
||||||
|
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
|
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
|
||||||
/// are unchanged.
|
/// are unchanged.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ pub struct ExportOptions {
|
|||||||
pub word2003: bool,
|
pub word2003: bool,
|
||||||
/// How digits look in PDFs and page images (the Numeral setting, `System` resolved).
|
/// How digits look in PDFs and page images (the Numeral setting, `System` resolved).
|
||||||
pub numeral: wordcraft_layout::NumeralMode,
|
pub numeral: wordcraft_layout::NumeralMode,
|
||||||
|
/// A PDF or page image is written only if nothing in it is silently missing (a character no
|
||||||
|
/// face can draw, a picture that is missing or can't be decoded); else the save fails with
|
||||||
|
/// `strict: …` (#6005). Ignored for other formats.
|
||||||
|
pub strict: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
|
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
|
|||||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||||
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
|
// HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An
|
||||||
|
// OpenDocument file says how big they are, and one wider than the text is meant to be;
|
||||||
|
// changing it would take its paragraph off being written as read. Its reader fits those
|
||||||
|
// that state no size.
|
||||||
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
||||||
r.map(|mut doc| {
|
r.map(|mut doc| {
|
||||||
crate::io::fit_pictures(&mut doc);
|
if !matches!(other, "odt" | "ott" | "fodt") {
|
||||||
|
crate::io::fit_pictures(&mut doc);
|
||||||
|
}
|
||||||
draw_charts(&mut doc);
|
draw_charts(&mut doc);
|
||||||
doc
|
doc
|
||||||
})
|
})
|
||||||
@@ -60,13 +65,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
|||||||
// Output shows the fields Word fills in as it prints (see `output_fields`).
|
// Output shows the fields Word fills in as it prints (see `output_fields`).
|
||||||
let out = crate::cmd::references::output_fields(doc);
|
let out = crate::cmd::references::output_fields(doc);
|
||||||
let doc = out.as_ref().unwrap_or(doc);
|
let doc = out.as_ref().unwrap_or(doc);
|
||||||
let pdf = wordcraft_pdf::PdfOptions {
|
Some(crate::strict::export_pdf(doc, opts))
|
||||||
include_markup: opts.markup,
|
|
||||||
comments: opts.markup && opts.comments,
|
|
||||||
numeral: opts.numeral,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
|
|
||||||
}
|
}
|
||||||
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
|
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
|
||||||
}
|
}
|
||||||
@@ -80,6 +79,12 @@ pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::Exp
|
|||||||
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
|
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
|
||||||
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
|
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
|
||||||
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||||
|
if opts.strict {
|
||||||
|
let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png);
|
||||||
|
if !report.passed() {
|
||||||
|
return Err(report.refusal());
|
||||||
|
}
|
||||||
|
}
|
||||||
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
|
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
|
||||||
Ok(img.to_png())
|
Ok(img.to_png())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod sample;
|
|||||||
mod session;
|
mod session;
|
||||||
pub(crate) mod snapshot;
|
pub(crate) mod snapshot;
|
||||||
pub mod speech;
|
pub mod speech;
|
||||||
|
pub mod strict;
|
||||||
pub(crate) mod view;
|
pub(crate) mod view;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub struct Prefs {
|
pub struct Prefs {
|
||||||
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
|
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
|
||||||
pub count_notes: bool,
|
pub count_notes: bool,
|
||||||
|
/// On-screen paper for pages without a document background; excluded from saved documents.
|
||||||
|
#[serde(deserialize_with = "lenient_paper_color")]
|
||||||
|
pub paper_color: crate::cmd::view::PaperColor,
|
||||||
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
|
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
|
||||||
/// Word).
|
/// Word).
|
||||||
pub markup: wordcraft_layout::display::MarkupOptions,
|
pub markup: wordcraft_layout::display::MarkupOptions,
|
||||||
@@ -64,6 +67,7 @@ pub struct Prefs {
|
|||||||
impl Default for Prefs {
|
impl Default for Prefs {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Prefs {
|
Prefs {
|
||||||
|
paper_color: Default::default(),
|
||||||
count_notes: true,
|
count_notes: true,
|
||||||
markup: Default::default(),
|
markup: Default::default(),
|
||||||
print_markup: false,
|
print_markup: false,
|
||||||
@@ -105,6 +109,11 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
|
|||||||
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
|
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
|
||||||
|
let value = serde_json::Value::deserialize(d)?;
|
||||||
|
Ok(serde_json::from_value(value).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
/// Ignore an invalid saved Page Color without discarding other preferences.
|
/// Ignore an invalid saved Page Color without discarding other preferences.
|
||||||
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
|
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
|
||||||
let value = serde_json::Value::deserialize(d)?;
|
let value = serde_json::Value::deserialize(d)?;
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ pub struct Session {
|
|||||||
pub merge: crate::cmd::mailings::MergeState,
|
pub merge: crate::cmd::mailings::MergeState,
|
||||||
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
|
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
|
||||||
pub ui_requests: Vec<Value>,
|
pub ui_requests: Vec<Value>,
|
||||||
|
/// View › Window: the other WordCraft windows and Side by Side (#322).
|
||||||
|
pub windows: crate::cmd::view::Windows,
|
||||||
/// Read Aloud player (Review › Speech).
|
/// Read Aloud player (Review › Speech).
|
||||||
pub read_aloud: crate::speech::ReadAloud,
|
pub read_aloud: crate::speech::ReadAloud,
|
||||||
/// Preferences the front end saves between runs.
|
/// Preferences the front end saves between runs.
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ impl Session {
|
|||||||
author: _,
|
author: _,
|
||||||
initials: _,
|
initials: _,
|
||||||
painter: _,
|
painter: _,
|
||||||
|
// What the drawn window knows of the others: its front end sets it as it draws.
|
||||||
|
windows: _,
|
||||||
status: _,
|
status: _,
|
||||||
last_command: _,
|
last_command: _,
|
||||||
recording: _,
|
recording: _,
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ impl Session {
|
|||||||
ribbon: crate::RibbonLayout::default(),
|
ribbon: crate::RibbonLayout::default(),
|
||||||
editing_lang: None,
|
editing_lang: None,
|
||||||
read_aloud: Default::default(),
|
read_aloud: Default::default(),
|
||||||
|
windows: Default::default(),
|
||||||
password,
|
password,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
recovery: None,
|
recovery: None,
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ impl Session {
|
|||||||
pending_ime_application_change: _,
|
pending_ime_application_change: _,
|
||||||
ime_origin: _,
|
ime_origin: _,
|
||||||
read_aloud: _,
|
read_aloud: _,
|
||||||
|
// The other windows, like `view`: not part of the document or its history.
|
||||||
|
windows: _,
|
||||||
prefs: _,
|
prefs: _,
|
||||||
system_region: _,
|
system_region: _,
|
||||||
keymap: _,
|
keymap: _,
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! Strict export (#6005): the report of what a PDF or PNG would leave out silently (characters no
|
||||||
|
//! face can draw, pictures that are missing or can't be decoded), and the refusal built from it.
|
||||||
|
//!
|
||||||
|
//! The check runs on the layout and display options the export itself uses, so what it reports is
|
||||||
|
//! what the output lacks.
|
||||||
|
|
||||||
|
use std::ops::RangeInclusive;
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture};
|
||||||
|
use wordcraft_layout::display::DisplayOptions;
|
||||||
|
use wordcraft_layout::{DocLayout, LayoutCache, StrictOutput};
|
||||||
|
|
||||||
|
use crate::io::ExportOptions;
|
||||||
|
|
||||||
|
/// The report's JSON schema version; fields may be added without raising it.
|
||||||
|
pub const SCHEMA: u32 = 1;
|
||||||
|
|
||||||
|
/// What a strict export found missing.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct StrictReport {
|
||||||
|
/// (code point, times drawn), sorted by code point.
|
||||||
|
pub glyphs: Vec<(char, u64)>,
|
||||||
|
pub pictures: Vec<MissingPicture>,
|
||||||
|
/// More distinct characters were missing than are listed.
|
||||||
|
pub glyphs_truncated: bool,
|
||||||
|
/// More pictures were missing than are listed.
|
||||||
|
pub pictures_truncated: bool,
|
||||||
|
/// Distinct characters found missing, those past the list's cap included.
|
||||||
|
pub total_glyphs: usize,
|
||||||
|
/// Pictures found missing, those past the list's cap included.
|
||||||
|
pub total_pictures: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StrictReport {
|
||||||
|
/// Nothing is missing: the strict export passes.
|
||||||
|
pub fn passed(&self) -> bool {
|
||||||
|
self.glyphs.is_empty() && self.pictures.is_empty() && !self.truncated()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either list was cut at its cap.
|
||||||
|
pub fn truncated(&self) -> bool {
|
||||||
|
self.glyphs_truncated || self.pictures_truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The schema-1 JSON of the report.
|
||||||
|
pub fn to_json(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"schema": SCHEMA,
|
||||||
|
"strict": if self.passed() { "passed" } else { "failed" },
|
||||||
|
"missing_glyphs": self.glyphs.iter().map(|(c, n)| json!({"cp": code_point(*c), "count": n})).collect::<Vec<_>>(),
|
||||||
|
"missing_pictures": self.pictures.iter().map(|p| json!({"part": p.part, "source_part": p.story, "reason": p.reason.code()})).collect::<Vec<_>>(),
|
||||||
|
"truncated": self.truncated(),
|
||||||
|
"total_missing_glyphs": self.total_glyphs,
|
||||||
|
"total_missing_pictures": self.total_pictures,
|
||||||
|
"glyphs_truncated": self.glyphs_truncated,
|
||||||
|
"pictures_truncated": self.pictures_truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The human lines (without the `strict: ` prefix): one for the characters, one for the
|
||||||
|
/// pictures; none when nothing is missing.
|
||||||
|
fn parts(&self) -> Vec<String> {
|
||||||
|
let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect();
|
||||||
|
let pictures: Vec<(String, String, String)> =
|
||||||
|
self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect();
|
||||||
|
human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The human lines, each `strict: …`.
|
||||||
|
pub fn lines(&self) -> Vec<String> {
|
||||||
|
self.parts().into_iter().map(|p| format!("strict: {p}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusal's message: `strict: …`, the lines joined.
|
||||||
|
pub fn refusal(&self) -> String {
|
||||||
|
format!("strict: {}", self.parts().join("; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The human text of a report: (code point text, count) and (part, story label, reason words).
|
||||||
|
fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec<String> {
|
||||||
|
let (glyphs_cut, pictures_cut) = cut;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if !glyphs.is_empty() {
|
||||||
|
let list: Vec<String> = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect();
|
||||||
|
let more = if glyphs_cut { ", …" } else { "" };
|
||||||
|
let listed = glyphs.len() as u64;
|
||||||
|
let n = if glyphs_cut { totals.0.max(listed) } else { listed };
|
||||||
|
let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() };
|
||||||
|
out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", ")));
|
||||||
|
}
|
||||||
|
if !pictures.is_empty() {
|
||||||
|
let list: Vec<String> = pictures
|
||||||
|
.iter()
|
||||||
|
.map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") })
|
||||||
|
.collect();
|
||||||
|
let more = if pictures_cut { "; …" } else { "" };
|
||||||
|
let listed = pictures.len() as u64;
|
||||||
|
let n = if pictures_cut { totals.1.max(listed) } else { listed };
|
||||||
|
let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() };
|
||||||
|
out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; ")));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `strict: …` human lines of a report in its JSON form (what `file.strictReport` returns).
|
||||||
|
/// Anything the JSON lacks is left out.
|
||||||
|
pub fn lines_from_json(report: &Value) -> Vec<String> {
|
||||||
|
let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||||
|
let glyphs: Vec<(String, u64)> = report
|
||||||
|
.get("missing_glyphs")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|a| a.iter().map(|g| (text(g, "cp"), g.get("count").and_then(Value::as_u64).unwrap_or(0))).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let pictures: Vec<(String, String, String)> = report
|
||||||
|
.get("missing_pictures")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|a| a.iter().map(|p| (text(p, "part"), story_label(&text(p, "source_part")).to_string(), reason_label(&text(p, "reason")))).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let flag = |k: &str| report.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
// A report without the per-list flags (the top-level one alone) marks both lists.
|
||||||
|
let (glyphs_cut, pictures_cut) = match (report.get("glyphs_truncated"), report.get("pictures_truncated")) {
|
||||||
|
(None, None) => (flag("truncated"), flag("truncated")),
|
||||||
|
_ => (flag("glyphs_truncated"), flag("pictures_truncated")),
|
||||||
|
};
|
||||||
|
let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64);
|
||||||
|
let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len()));
|
||||||
|
human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The words for a JSON reason code; a code this version does not know is shown as it is.
|
||||||
|
fn reason_label(code: &str) -> String {
|
||||||
|
wordcraft_doc::MissingPictureReason::from_code(code).map_or_else(|| code.to_string(), |r| r.label().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `U+2D30`: upper-case hex, at least four digits.
|
||||||
|
fn code_point(c: char) -> String {
|
||||||
|
format!("U+{:04X}", c as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The part a picture is in, as the report names it: `header2.xml` for `word/header2.xml`.
|
||||||
|
fn story_label(story: &str) -> &str {
|
||||||
|
story.strip_prefix("word/").unwrap_or(story)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The report of `doc` as drawn by `lay` with `display` into `output`, on `pages` (0-based; `None`
|
||||||
|
/// is every page). The watermark is checked with the page, and the pictures dropped when the
|
||||||
|
/// document was opened are added to those that can't be decoded now, capped like each.
|
||||||
|
pub fn strict_report(
|
||||||
|
doc: &Document,
|
||||||
|
lay: &DocLayout,
|
||||||
|
display: &DisplayOptions,
|
||||||
|
pages: Option<RangeInclusive<usize>>,
|
||||||
|
output: StrictOutput,
|
||||||
|
) -> StrictReport {
|
||||||
|
let mut missing = lay.missing_glyphs(doc, display, pages.clone());
|
||||||
|
let any_page = lay.pages.iter().enumerate().any(|(n, _)| pages.as_ref().is_none_or(|r| r.contains(&n)));
|
||||||
|
if any_page && let Some(wm) = &doc.settings.watermark {
|
||||||
|
let max = match output {
|
||||||
|
StrictOutput::Pdf => wordcraft_pdf::WATERMARK_MAX_CHARS,
|
||||||
|
StrictOutput::Png => usize::MAX,
|
||||||
|
};
|
||||||
|
missing.merge(wordcraft_layout::missing_watermark_glyphs(wm, max));
|
||||||
|
}
|
||||||
|
let (undecodable, mut pictures_truncated, undecodable_total) = wordcraft_layout::undecodable_pictures(doc, lay, pages, display, output);
|
||||||
|
pictures_truncated |= doc.missing_pictures.truncated;
|
||||||
|
// The two lists overlap where the same picture is in both; those are counted once.
|
||||||
|
let mut overlap: u64 = 0;
|
||||||
|
let mut pictures: Vec<MissingPicture> = Vec::new();
|
||||||
|
for p in doc.missing_pictures.list.iter().cloned().chain(undecodable) {
|
||||||
|
if pictures.contains(&p) {
|
||||||
|
overlap = overlap.saturating_add(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pictures.len() >= MAX_MISSING_PICTURES {
|
||||||
|
pictures_truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pictures.push(p);
|
||||||
|
}
|
||||||
|
let total_pictures = doc.missing_pictures.total.saturating_add(undecodable_total).saturating_sub(overlap).max(pictures.len() as u64);
|
||||||
|
StrictReport {
|
||||||
|
glyphs: missing.chars,
|
||||||
|
pictures,
|
||||||
|
glyphs_truncated: missing.truncated,
|
||||||
|
pictures_truncated,
|
||||||
|
total_glyphs: missing.total,
|
||||||
|
total_pictures,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PDF export's options for `opts`: the one place they are built, shared by the export and
|
||||||
|
/// its strict check.
|
||||||
|
pub(crate) fn pdf_options(opts: &ExportOptions) -> wordcraft_pdf::PdfOptions {
|
||||||
|
wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a page image shows: the display options `file.exportPng` renders with.
|
||||||
|
pub(crate) fn png_display() -> DisplayOptions {
|
||||||
|
// A page image is the printed page: headers and footers at full strength.
|
||||||
|
let mut opts = wordcraft_render::RenderOptions::default();
|
||||||
|
opts.display.dim_header = false;
|
||||||
|
opts.display
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The strict report of a PDF export of `doc`: every page, laid out as the export lays it out.
|
||||||
|
pub(crate) fn pdf_report(doc: &Document, opts: &ExportOptions) -> StrictReport {
|
||||||
|
let pdf = pdf_options(opts);
|
||||||
|
let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options());
|
||||||
|
strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PDF of `doc`; with `opts.strict`, only if nothing is missing, laid out once for the check
|
||||||
|
/// and the export.
|
||||||
|
pub(crate) fn export_pdf(doc: &Document, opts: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||||
|
let pdf = pdf_options(opts);
|
||||||
|
if !opts.strict {
|
||||||
|
return wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string());
|
||||||
|
}
|
||||||
|
let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options());
|
||||||
|
let report = strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf);
|
||||||
|
if !report.passed() {
|
||||||
|
return Err(report.refusal());
|
||||||
|
}
|
||||||
|
wordcraft_pdf::export_layout(doc, &lay, &pdf).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `path` a PDF or PNG output (the only outputs a strict export applies to)?
|
||||||
|
pub(crate) fn is_strict_output(path: &str) -> bool {
|
||||||
|
let lower = path.to_ascii_lowercase();
|
||||||
|
lower.ends_with(".pdf") || lower.ends_with(".png")
|
||||||
|
}
|
||||||
+224
-1
@@ -16,6 +16,7 @@ mod find_apostrophes;
|
|||||||
mod font_sizes;
|
mod font_sizes;
|
||||||
mod fonts;
|
mod fonts;
|
||||||
mod ime_noop;
|
mod ime_noop;
|
||||||
|
mod para_tools;
|
||||||
mod revision_checks;
|
mod revision_checks;
|
||||||
mod typing;
|
mod typing;
|
||||||
mod wordart;
|
mod wordart;
|
||||||
@@ -4682,7 +4683,7 @@ fn typed_paragraphs_are_capped_for_every_caller() {
|
|||||||
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
|
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hostile_junk() -> [serde_json::Value; 8] {
|
fn hostile_junk() -> [serde_json::Value; 10] {
|
||||||
[
|
[
|
||||||
json!(null),
|
json!(null),
|
||||||
json!({}),
|
json!({}),
|
||||||
@@ -4692,6 +4693,9 @@ fn hostile_junk() -> [serde_json::Value; 8] {
|
|||||||
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
|
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
|
||||||
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
|
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
|
||||||
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
|
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
|
||||||
|
// Strict export (#6005): the flag, and file.strictReport's `page`, with junk.
|
||||||
|
json!({"strict": true, "page": 0, "scale": 0, "markup": true}),
|
||||||
|
json!({"strict": "x", "page": 1e30, "scale": 1e-9, "markup": "x"}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4788,6 +4792,9 @@ fn hostile_file_params_never_panic() {
|
|||||||
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
|
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
|
||||||
json!({"path": path, "password": 5, "format": 5, "markup": true}),
|
json!({"path": path, "password": 5, "format": 5, "markup": true}),
|
||||||
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
|
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
|
||||||
|
json!({"path": path, "strict": true, "page": 1, "scale": 0.1}),
|
||||||
|
json!({"path": path, "strict": true, "page": 1e30, "markup": true, "format": "flatopc"}),
|
||||||
|
json!({"path": path, "strict": 5, "page": -1}),
|
||||||
json!({"path": path, "data": "not base64!"}),
|
json!({"path": path, "data": "not base64!"}),
|
||||||
json!({"path": path, "data": "AAAA"}),
|
json!({"path": path, "data": "AAAA"}),
|
||||||
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
|
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
|
||||||
@@ -5217,6 +5224,42 @@ fn arrange_bounds_resizes_and_moves_objects() {
|
|||||||
assert_eq!(box_area(&mut s).rect, n);
|
assert_eq!(box_area(&mut s).rect, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A frame anchored to a page and moved in WordCraft is placed by its paragraph from then on: the
|
||||||
|
/// file's page number goes, or the save would put it back. Only resized, it stays on that page.
|
||||||
|
#[test]
|
||||||
|
fn an_odt_frame_moved_from_its_page_loses_the_page_number() {
|
||||||
|
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {ns}><office:body><office:text><draw:frame text:anchor-type="page" text:anchor-page-number="3" svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p><text:p>Two</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||||
|
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||||
|
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||||
|
z.start_file(name, o).unwrap();
|
||||||
|
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
let odt = z.finish().unwrap().into_inner();
|
||||||
|
let frame_tag = |s: &Session| {
|
||||||
|
let mut a = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
|
||||||
|
let mut c = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut a.by_name("content.xml").unwrap(), &mut c).unwrap();
|
||||||
|
c.split("<draw:frame").nth(1).and_then(|t| t.split('>').next()).unwrap_or_default().to_string()
|
||||||
|
};
|
||||||
|
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||||
|
select_box(&mut s);
|
||||||
|
run(&mut s, "arrange.bounds", json!({"width": 150, "height": 80}));
|
||||||
|
assert!(frame_tag(&s).contains(r#"text:anchor-page-number="3""#), "resized: {}", frame_tag(&s));
|
||||||
|
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||||
|
select_box(&mut s);
|
||||||
|
run(&mut s, "arrange.bounds", json!({"page": 0, "x": 200, "y": 300}));
|
||||||
|
let tag = frame_tag(&s);
|
||||||
|
assert!(!tag.contains("anchor-page-number") && !tag.contains(r#"text:anchor-type="page""#), "moved: {tag}");
|
||||||
|
// Moved, then placed by the margins: the file's page number is gone, not only hidden.
|
||||||
|
run(&mut s, "arrange.position", json!({"preset": "topLeft"}));
|
||||||
|
let tag = frame_tag(&s);
|
||||||
|
assert!(!tag.contains("anchor-page-number"), "moved, then placed: {tag}");
|
||||||
|
}
|
||||||
|
|
||||||
/// An object dropped near the bottom of a page lands there, though at the top of the paragraph
|
/// An object dropped near the bottom of a page lands there, though at the top of the paragraph
|
||||||
/// it anchors to it would push that paragraph onto the next page (#3169).
|
/// it anchors to it would push that paragraph onto the next page (#3169).
|
||||||
#[test]
|
#[test]
|
||||||
@@ -10788,6 +10831,44 @@ fn insert_picture_takes_a_windows_metafile() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_commands_need_the_desktop_and_name_a_window() {
|
||||||
|
use crate::cmd::view::OtherWindow;
|
||||||
|
let mut s = s();
|
||||||
|
// Web, CLI, MCP: one window, so View › Window is disabled.
|
||||||
|
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
|
||||||
|
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
|
||||||
|
}
|
||||||
|
s.windows.available = true;
|
||||||
|
run(&mut s, "view.arrangeAll", json!({}));
|
||||||
|
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
|
||||||
|
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
|
||||||
|
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
|
||||||
|
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
|
||||||
|
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
|
||||||
|
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
|
||||||
|
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
|
||||||
|
// Side by Side pairs with one window and scrolls together until turned off.
|
||||||
|
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
|
||||||
|
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
|
||||||
|
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
|
||||||
|
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
|
||||||
|
run(&mut s, "view.syncScroll", json!({}));
|
||||||
|
assert!(!s.windows.sync_scroll);
|
||||||
|
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
|
||||||
|
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
|
||||||
|
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
|
||||||
|
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
|
||||||
|
assert!(s.ui_requests.is_empty());
|
||||||
|
// Another partner replaces the first; the bare button turns it off.
|
||||||
|
run(&mut s, "view.sideBySide", json!({"window": 9}));
|
||||||
|
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
|
||||||
|
run(&mut s, "view.sideBySide", json!({}));
|
||||||
|
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
|
||||||
|
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
|
||||||
|
assert!(!s.dirty, "window commands never touch the document");
|
||||||
|
}
|
||||||
|
|
||||||
/// Change Picture must keep the replacement's own format: the part's name and content type
|
/// Change Picture must keep the replacement's own format: the part's name and content type
|
||||||
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
|
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
|
||||||
/// and LibreOffice then show a broken picture).
|
/// and LibreOffice then show a broken picture).
|
||||||
@@ -12568,6 +12649,40 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
|
||||||
|
use crate::cmd::view::PaperColor;
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
|
||||||
|
let before = serde_json::to_value(&s.doc).unwrap();
|
||||||
|
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
|
||||||
|
s.dirty = false;
|
||||||
|
let dirty = s.dirty;
|
||||||
|
s.view.dark_mode = true;
|
||||||
|
for (value, expected) in
|
||||||
|
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
|
||||||
|
{
|
||||||
|
run(&mut s, "view.paperColor", json!({"value": value}));
|
||||||
|
assert_eq!(s.prefs.paper_color, expected);
|
||||||
|
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||||
|
assert_eq!(s.dirty, dirty);
|
||||||
|
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
|
||||||
|
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
|
||||||
|
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||||
|
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
|
||||||
|
assert_eq!(restored.paper_color, expected);
|
||||||
|
}
|
||||||
|
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
|
||||||
|
assert!(s.run("view.paperColor", &v).is_err());
|
||||||
|
assert_eq!(s.prefs.paper_color, PaperColor::Document);
|
||||||
|
}
|
||||||
|
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
|
||||||
|
assert_eq!(legacy.paper_color, PaperColor::Document);
|
||||||
|
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
|
||||||
|
assert_eq!(future.paper_color, PaperColor::Document);
|
||||||
|
assert!(!future.count_notes);
|
||||||
|
}
|
||||||
|
|
||||||
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
|
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
|
||||||
/// draws one per character; Format state reports it, and none takes it off.
|
/// draws one per character; Format state reports it, and none takes it off.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -12615,6 +12730,73 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
|
|||||||
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
|
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays
|
||||||
|
/// that wide, and its paragraph is written as read: opening a file changes nothing in it.
|
||||||
|
#[test]
|
||||||
|
fn an_odts_picture_wider_than_the_text_keeps_its_size() {
|
||||||
|
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-left="3cm" fo:margin-right="3cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame draw:name="Rule" text:anchor-type="paragraph" svg:x="-1cm" svg:width="18cm" svg:height="0.2cm"><draw:image xlink:href="Pictures/r.png"/></draw:frame>Text</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
|
||||||
|
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||||
|
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||||
|
for (name, data) in [
|
||||||
|
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||||
|
("content.xml", content.as_bytes()),
|
||||||
|
("styles.xml", styles.as_bytes()),
|
||||||
|
("Pictures/r.png", &png),
|
||||||
|
] {
|
||||||
|
z.start_file(name, o).unwrap();
|
||||||
|
std::io::Write::write_all(&mut z, data).unwrap();
|
||||||
|
}
|
||||||
|
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||||
|
let width = doc.body[0]
|
||||||
|
.as_para()
|
||||||
|
.unwrap()
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None });
|
||||||
|
let want = 18.0 * 72.0 / 2.54;
|
||||||
|
assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text");
|
||||||
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
|
||||||
|
let mut saved = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap();
|
||||||
|
assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Numbering or a new style makes the styles be written from the model on save: a document in
|
||||||
|
/// German stays in German, rather than taking the default language of what opens it next.
|
||||||
|
#[test]
|
||||||
|
fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
|
||||||
|
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {ns}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="de" fo:country="DE"/></style:default-style></office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {ns}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||||
|
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||||
|
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("styles.xml", styles.as_str())]
|
||||||
|
{
|
||||||
|
z.start_file(name, o).unwrap();
|
||||||
|
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
let odt = z.finish().unwrap().into_inner();
|
||||||
|
for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] {
|
||||||
|
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
|
||||||
|
assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English");
|
||||||
|
run(&mut s, command, params);
|
||||||
|
let saved = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||||
|
let back = crate::io::open_bytes("t.odt", &saved).unwrap();
|
||||||
|
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
|
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
|
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
|
||||||
@@ -12859,6 +13041,47 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
|||||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other
|
||||||
|
/// formats' are; one with a stated size keeps it, in line too.
|
||||||
|
#[test]
|
||||||
|
fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
|
||||||
|
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let mut png = std::io::Cursor::new(Vec::new());
|
||||||
|
image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||||
|
let png = png.into_inner();
|
||||||
|
let open = |attrs: &str| {
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||||
|
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||||
|
for (name, data) in [
|
||||||
|
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
|
||||||
|
("content.xml", content.as_bytes()),
|
||||||
|
("styles.xml", styles.as_bytes()),
|
||||||
|
("Pictures/a.png", &png),
|
||||||
|
] {
|
||||||
|
z.start_file(name, o).unwrap();
|
||||||
|
std::io::Write::write_all(&mut z, data).unwrap();
|
||||||
|
}
|
||||||
|
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||||
|
doc.body[0]
|
||||||
|
.as_para()
|
||||||
|
.unwrap()
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None })
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let (w, h) = open("");
|
||||||
|
assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}");
|
||||||
|
let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#);
|
||||||
|
assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole
|
||||||
|
//! document, one undo step, Track Changes respected.
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef};
|
||||||
|
|
||||||
|
use crate::Session;
|
||||||
|
|
||||||
|
fn s() -> Session {
|
||||||
|
Session::new(wordcraft_doc::Document::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
|
||||||
|
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(s: &Session) -> String {
|
||||||
|
s.doc.plain_text(StoryRef::Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the whole document (Mod+A's command).
|
||||||
|
fn select_all(s: &mut Session) {
|
||||||
|
run(s, "select.all", json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One paragraph with `\n` as manual line breaks, typed as text.
|
||||||
|
fn type_breaks(s: &mut Session, t: &str) {
|
||||||
|
run(s, "text.insert", json!({"text": t}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_breaks_become_paragraphs() {
|
||||||
|
let mut s = s();
|
||||||
|
type_breaks(&mut s, "one\ntwo\nthree");
|
||||||
|
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text");
|
||||||
|
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3);
|
||||||
|
// One undo step brings the manual line breaks back.
|
||||||
|
assert!(s.undo());
|
||||||
|
let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_paragraphs_go_in_between() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.blankBetween", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a\n\nb\n\nc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_paragraphs_go_after_too() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true}));
|
||||||
|
assert_eq!(text(&s), "a\n\nb\n\nc\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn separate_paragraphs_blanks_after_every_one() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.separateParagraphs", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a\n\nb\n\nc\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_paragraphs_are_deleted() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a\nb\nc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_section_break_paragraph_stays() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a\nb"}));
|
||||||
|
// The second paragraph carries a section break and is emptied: removing it would drop the
|
||||||
|
// section, so it stays.
|
||||||
|
let second = Path::top(1);
|
||||||
|
let p = s.doc.para_mut(StoryRef::Body, &second).unwrap();
|
||||||
|
p.text.clear();
|
||||||
|
p.runs.clear();
|
||||||
|
p.section = Some(Box::new(SectionProps::default()));
|
||||||
|
p.touch();
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
|
||||||
|
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leading_spaces_are_removed() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": " a\nb\n c"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a\nb\nc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_spaces_collapse_to_one() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a b c d \n e"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_space_run_at_the_end_collapses() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a "}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() {
|
||||||
|
use wordcraft_doc::para::PAGE_BREAK;
|
||||||
|
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indented_spaces_become_an_indent() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": " a\nb"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a\nb");
|
||||||
|
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt");
|
||||||
|
assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() {
|
||||||
|
let mut many = s();
|
||||||
|
run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))}));
|
||||||
|
select_all(&mut many);
|
||||||
|
run(&mut many, "para.spacesToIndent", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text");
|
||||||
|
assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0));
|
||||||
|
|
||||||
|
let mut nearly_full = s();
|
||||||
|
run(&mut nearly_full, "document.setText", json!({"text": " a"}));
|
||||||
|
run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0}));
|
||||||
|
select_all(&mut nearly_full);
|
||||||
|
run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space");
|
||||||
|
assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indents_become_leading_spaces() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||||
|
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), " a", "72 + 144 pt become three spaces");
|
||||||
|
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hanging_indent_that_sums_to_zero_is_still_removed() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||||
|
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces");
|
||||||
|
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
|
||||||
|
|
||||||
|
let mut tracked = Session::new(wordcraft_doc::Document::new());
|
||||||
|
run(&mut tracked, "document.setText", json!({"text": "a"}));
|
||||||
|
run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
|
||||||
|
run(&mut tracked, "review.trackChanges", json!({"value": true}));
|
||||||
|
select_all(&mut tracked);
|
||||||
|
run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"}));
|
||||||
|
assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision");
|
||||||
|
assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "aa bb "}));
|
||||||
|
s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) };
|
||||||
|
run(&mut s, "format.bold", json!({"value": true}));
|
||||||
|
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||||
|
run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"}));
|
||||||
|
let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||||
|
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||||
|
run(&mut s, "text.insert", json!({"text": " tail"}));
|
||||||
|
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
|
||||||
|
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting");
|
||||||
|
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays");
|
||||||
|
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays");
|
||||||
|
assert!(s.doc.parts.contains_key(¬e), "the anchored footnote story stays");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_inside_a_table_keeps_the_table_and_its_cell() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||||
|
run(&mut s, "text.insert", json!({"text": "a b"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
|
||||||
|
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays");
|
||||||
|
assert_eq!(cell.text, "a b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whole_document_scope_from_a_table_cell_reaches_the_body() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "body text"}));
|
||||||
|
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||||
|
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||||
|
let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32;
|
||||||
|
s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 });
|
||||||
|
run(&mut s, "text.insert", json!({"text": "cell text"}));
|
||||||
|
assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table");
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s));
|
||||||
|
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
|
||||||
|
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays");
|
||||||
|
assert_eq!(cell.text, "cell text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paragraph_tools_refuse_content_locked_controls() {
|
||||||
|
for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] {
|
||||||
|
let mut s = s();
|
||||||
|
type_breaks(&mut s, text);
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "developer.richText", json!({"tag": "locked"}));
|
||||||
|
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
|
||||||
|
select_all(&mut s);
|
||||||
|
let before = s.doc.clone();
|
||||||
|
let error = s.run(command, &json!({"scope": "all"})).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("can't be edited"), "{command}: {error}");
|
||||||
|
assert_eq!(s.doc, before, "{command} changed locked content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_breaks_are_removed() {
|
||||||
|
let mut s = s();
|
||||||
|
type_breaks(&mut s, "one\ntwo\nthree");
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeLineBreaks", json!({"scope": "all"}));
|
||||||
|
assert_eq!(text(&s), "onetwothree");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_selection_is_the_scope() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a b\nc d"}));
|
||||||
|
// Only the first paragraph: select from its start to its end.
|
||||||
|
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) };
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({}));
|
||||||
|
assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structural_tools_do_not_cross_the_selection() {
|
||||||
|
let mut breaks = s();
|
||||||
|
type_breaks(&mut breaks, "one\ntwo");
|
||||||
|
run(&mut breaks, "text.newParagraph", json!({}));
|
||||||
|
run(&mut breaks, "text.insert", json!({"text": "outside"}));
|
||||||
|
breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) };
|
||||||
|
run(&mut breaks, "para.convertBreaks", json!({}));
|
||||||
|
let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap();
|
||||||
|
assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside");
|
||||||
|
|
||||||
|
let mut between = s();
|
||||||
|
run(&mut between, "document.setText", json!({"text": "a\nb\nc"}));
|
||||||
|
between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
|
||||||
|
run(&mut between, "para.blankBetween", json!({}));
|
||||||
|
assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them");
|
||||||
|
|
||||||
|
let mut separate = s();
|
||||||
|
run(&mut separate, "document.setText", json!({"text": "a\nb\nc"}));
|
||||||
|
separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
|
||||||
|
run(&mut separate, "para.separateParagraphs", json!({}));
|
||||||
|
assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c");
|
||||||
|
|
||||||
|
let mut blank = s();
|
||||||
|
run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"}));
|
||||||
|
blank.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||||
|
run(&mut blank, "para.removeBlank", json!({}));
|
||||||
|
assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indent_space_conversions_are_reversible_track_changes() {
|
||||||
|
let mut to_indent = s();
|
||||||
|
run(&mut to_indent, "document.setText", json!({"text": " a"}));
|
||||||
|
run(&mut to_indent, "review.trackChanges", json!({"value": true}));
|
||||||
|
select_all(&mut to_indent);
|
||||||
|
run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"}));
|
||||||
|
assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0));
|
||||||
|
run(&mut to_indent, "review.rejectAll", json!({}));
|
||||||
|
let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None));
|
||||||
|
|
||||||
|
let mut to_spaces = s();
|
||||||
|
run(&mut to_spaces, "document.setText", json!({"text": "a"}));
|
||||||
|
run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0}));
|
||||||
|
run(&mut to_spaces, "review.trackChanges", json!({"value": true}));
|
||||||
|
select_all(&mut to_spaces);
|
||||||
|
run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"}));
|
||||||
|
run(&mut to_spaces, "review.rejectAll", json!({}));
|
||||||
|
let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0)));
|
||||||
|
|
||||||
|
let mut accepted = s();
|
||||||
|
run(&mut accepted, "document.setText", json!({"text": "a"}));
|
||||||
|
run(&mut accepted, "para.indents", json!({"firstLine": 144.0}));
|
||||||
|
run(&mut accepted, "review.trackChanges", json!({"value": true}));
|
||||||
|
select_all(&mut accepted);
|
||||||
|
run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"}));
|
||||||
|
run(&mut accepted, "review.acceptAll", json!({}));
|
||||||
|
let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||||
|
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indent_to_spaces_does_not_inherit_revision_marks() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a"}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
|
||||||
|
run(&mut s, "format.bold", json!({"value": true}));
|
||||||
|
run(&mut s, "text.delete", json!({}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": false}));
|
||||||
|
run(&mut s, "para.indents", json!({"firstLine": 72.0}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
|
||||||
|
let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0);
|
||||||
|
assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converting_too_many_line_breaks_is_atomic() {
|
||||||
|
let original = format!("{}end", "x\n".repeat(10_001));
|
||||||
|
let mut s = s();
|
||||||
|
type_breaks(&mut s, &original);
|
||||||
|
select_all(&mut s);
|
||||||
|
let before = s.doc.clone();
|
||||||
|
let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("at most"), "{error}");
|
||||||
|
assert_eq!(s.doc, before, "the refused conversion is all-or-nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeating_a_tracked_deletion_creates_no_empty_revision() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": " a"}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||||
|
let revisions = s.doc.revisions.len();
|
||||||
|
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||||
|
assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletions_are_recorded_under_track_changes() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a b\n c"}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
|
||||||
|
// The text stays marked deleted, not gone, as Word records the deletion.
|
||||||
|
assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text");
|
||||||
|
assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete));
|
||||||
|
run(&mut s, "review.acceptAll", json!({}));
|
||||||
|
assert_eq!(text(&s), "a b\nc", "accepting removes the leading space");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on).
|
||||||
|
fn track_delete_in_first(s: &mut Session, a: usize, b: usize) {
|
||||||
|
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||||
|
let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||||
|
crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_indent(s: &Session) -> Option<f32> {
|
||||||
|
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||||
|
s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_deleted(s: &Session, off: usize) -> bool {
|
||||||
|
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
|
||||||
|
s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_extra_spaces_skips_tracked_deleted_spaces() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
track_delete_in_first(&mut s, 2, 3); // the second space is already deleted
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
run(&mut s, "review.acceptAll", json!({}));
|
||||||
|
assert_eq!(text(&s), "a b", "the one live space survives");
|
||||||
|
|
||||||
|
// Three spaces, the middle one already deleted: of the two live ones the last is kept.
|
||||||
|
let mut s = self::s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
track_delete_in_first(&mut s, 2, 3);
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
|
||||||
|
assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted");
|
||||||
|
assert!(!first_deleted(&s, 3), "the last live space stays");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": " x"}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||||
|
assert_eq!(first_indent(&s), Some(144.0));
|
||||||
|
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
|
||||||
|
assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_breaks_leaves_a_tracked_deleted_break_alone() {
|
||||||
|
let mut s = s();
|
||||||
|
type_breaks(&mut s, "a\nb\nc");
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
track_delete_in_first(&mut s, 1, 2);
|
||||||
|
select_all(&mut s);
|
||||||
|
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
|
||||||
|
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn junk_scope_is_refused() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "document.setText", json!({"text": "a b"}));
|
||||||
|
select_all(&mut s);
|
||||||
|
let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err();
|
||||||
|
assert!(format!("{e}").contains("scope"), "{e}");
|
||||||
|
assert_eq!(text(&s), "a b", "the document is unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_many_paragraphs_are_refused_before_any_change() {
|
||||||
|
let text = "x\n".repeat(15_000);
|
||||||
|
let mut s = Session::new(wordcraft_doc::Document::from_text(&text));
|
||||||
|
select_all(&mut s);
|
||||||
|
let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err();
|
||||||
|
assert!(format!("{e}").contains("at most"), "{e}");
|
||||||
|
let kept = s.doc.para_paths(StoryRef::Body).len();
|
||||||
|
assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}");
|
||||||
|
}
|
||||||
@@ -3876,6 +3876,88 @@ fn backspace_cannot_join_into_locked_content_control() {
|
|||||||
assert_eq!(s.doc, before);
|
assert_eq!(s.doc, before);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour.
|
||||||
|
#[test]
|
||||||
|
fn delete_previous_sentence_respects_caret_boundaries_and_unicode() {
|
||||||
|
for (text, before, expected) in [
|
||||||
|
("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "),
|
||||||
|
("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."),
|
||||||
|
("First sentence. Second sentence.", "First sentence. ", "Second sentence."),
|
||||||
|
("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "),
|
||||||
|
("No punctuation here", "No punctuation", " here"),
|
||||||
|
("One. Two.", "", "One. Two."),
|
||||||
|
("", "", ""),
|
||||||
|
] {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "text.insert", json!({"text": text, "raw": true}));
|
||||||
|
let mut pos = s.sel.focus.clone();
|
||||||
|
pos.off = before.len();
|
||||||
|
s.sel = crate::Selection::caret(pos);
|
||||||
|
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}");
|
||||||
|
if expected != text {
|
||||||
|
run(&mut s, "edit.undo", json!({}));
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text);
|
||||||
|
run(&mut s, "edit.redo", json!({}));
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true}));
|
||||||
|
let end = s.sel.focus.clone();
|
||||||
|
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
|
||||||
|
let count = s.doc.body.len();
|
||||||
|
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||||
|
assert_eq!(s.doc.body.len(), count);
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
|
||||||
|
s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } };
|
||||||
|
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence.");
|
||||||
|
run(&mut s, "edit.undo", json!({}));
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_previous_sentence_tracks_changes_and_respects_read_only() {
|
||||||
|
for accept in [true, false] {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true}));
|
||||||
|
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||||
|
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||||
|
let changes = s.run("review.changes", &json!({})).unwrap();
|
||||||
|
assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete"));
|
||||||
|
run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({}));
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." });
|
||||||
|
}
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true}));
|
||||||
|
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
|
||||||
|
assert!(s.run("text.deleteSentenceBack", &json!({})).is_err());
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() {
|
||||||
|
let mut s = s();
|
||||||
|
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
|
||||||
|
run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true}));
|
||||||
|
run(&mut s, "text.tab", json!({}));
|
||||||
|
run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true}));
|
||||||
|
let end = s.sel.focus.clone();
|
||||||
|
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
|
||||||
|
let before = s.doc.plain_text(StoryRef::Body);
|
||||||
|
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||||
|
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
|
||||||
|
s.sel = crate::Selection::caret(end);
|
||||||
|
run(&mut s, "text.deleteSentenceBack", json!({}));
|
||||||
|
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "");
|
||||||
|
assert!(s.doc.plain_text(StoryRef::Body).contains("First cell."));
|
||||||
|
}
|
||||||
|
|
||||||
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
|
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
|
||||||
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
|
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
//! Strict export (#6005): `strict: true` on the PDF and PNG exports refuses an output that would
|
||||||
|
//! silently lack something, before any byte is written; `file.strictReport` says what.
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use wordcraft_doc::Document;
|
||||||
|
use wordcraft_engine::{CmdError, Session};
|
||||||
|
use wordcraft_fonts::FontDb;
|
||||||
|
|
||||||
|
/// Code points in unassigned plane 4 that no face of the host covers (as the layout tests find them).
|
||||||
|
fn uncovered_many(n: usize) -> Vec<char> {
|
||||||
|
let db = FontDb::global();
|
||||||
|
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
|
||||||
|
let out: Vec<char> = (0x40000u32..0x4FFF0)
|
||||||
|
.filter_map(char::from_u32)
|
||||||
|
.filter(|c| {
|
||||||
|
!primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none()
|
||||||
|
})
|
||||||
|
.take(n)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(out.len(), n, "the host has faces covering all of plane 4");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uncovered() -> char {
|
||||||
|
uncovered_many(1)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let d = std::env::temp_dir().join(format!("wc-strict-export-{name}-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&d).unwrap();
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refused(r: Result<Value, CmdError>) -> String {
|
||||||
|
match r {
|
||||||
|
Err(CmdError::Failed(m)) => {
|
||||||
|
assert!(m.starts_with("strict: "), "{m}");
|
||||||
|
m
|
||||||
|
}
|
||||||
|
other => panic!("expected a strict refusal, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_refuses_an_uncovered_character_and_writes_nothing() {
|
||||||
|
let c = uncovered();
|
||||||
|
let d = dir("refuse");
|
||||||
|
let mut s = Session::new(Document::from_text(&format!("a{c}b")));
|
||||||
|
let pdf = d.join("out.pdf");
|
||||||
|
let png = d.join("out.png");
|
||||||
|
let msg = refused(s.run("file.exportPdf", &json!({"path": pdf, "strict": true})));
|
||||||
|
assert!(msg.contains(&format!("U+{:04X} (×1)", c as u32)), "{msg}");
|
||||||
|
refused(s.run("file.save", &json!({"path": pdf, "strict": true})));
|
||||||
|
refused(s.run("file.saveAs", &json!({"path": pdf, "strict": true})));
|
||||||
|
refused(s.run("file.exportPng", &json!({"path": png, "strict": true})));
|
||||||
|
refused(s.run("file.save", &json!({"path": png, "strict": true})));
|
||||||
|
assert!(!pdf.exists() && !png.exists());
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_leaves_an_existing_output_untouched() {
|
||||||
|
let c = uncovered();
|
||||||
|
let d = dir("keep");
|
||||||
|
let pdf = d.join("out.pdf");
|
||||||
|
std::fs::write(&pdf, b"previous").unwrap();
|
||||||
|
let mut s = Session::new(Document::from_text(&format!("a{c}")));
|
||||||
|
refused(s.run("file.exportPdf", &json!({"path": pdf, "strict": true})));
|
||||||
|
assert_eq!(std::fs::read(&pdf).unwrap(), b"previous");
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_save_leaves_the_stamps_path_and_dirty_flag_alone() {
|
||||||
|
let c = uncovered();
|
||||||
|
let d = dir("stamp");
|
||||||
|
let mut s = Session::new(Document::from_text(&format!("a{c}")));
|
||||||
|
s.dirty = true;
|
||||||
|
let before =
|
||||||
|
(s.doc.core.modified.clone(), s.doc.core.created.clone(), s.doc.core.revision, s.doc.core.last_modified_by.clone(), s.doc.settings.zoom);
|
||||||
|
refused(s.run("file.save", &json!({"path": d.join("out.pdf"), "strict": true})));
|
||||||
|
let after =
|
||||||
|
(s.doc.core.modified.clone(), s.doc.core.created.clone(), s.doc.core.revision, s.doc.core.last_modified_by.clone(), s.doc.settings.zoom);
|
||||||
|
assert_eq!(before, after);
|
||||||
|
assert!(s.dirty);
|
||||||
|
assert_eq!(s.path, None);
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_passes_and_writes_when_nothing_is_missing() {
|
||||||
|
let d = dir("pass");
|
||||||
|
let mut s = Session::new(Document::from_text("Hello, world."));
|
||||||
|
for (cmd, file, magic) in [
|
||||||
|
("file.exportPdf", "a.pdf", &b"%PDF-"[..]),
|
||||||
|
("file.save", "b.pdf", &b"%PDF-"[..]),
|
||||||
|
("file.exportPng", "a.png", &b"\x89PNG"[..]),
|
||||||
|
("file.save", "b.png", &b"\x89PNG"[..]),
|
||||||
|
] {
|
||||||
|
let path = d.join(file);
|
||||||
|
s.run(cmd, &json!({"path": path, "strict": true})).unwrap();
|
||||||
|
assert!(std::fs::read(&path).unwrap().starts_with(magic), "{file}");
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_passes_a_document_the_same_pdf_as_without_it() {
|
||||||
|
let d = dir("pdfsame");
|
||||||
|
let mut s = Session::new(Document::from_text("Plain text"));
|
||||||
|
s.run("file.exportPdf", &json!({"path": d.join("a.pdf")})).unwrap();
|
||||||
|
s.run("file.exportPdf", &json!({"path": d.join("b.pdf"), "strict": true})).unwrap();
|
||||||
|
let (a, b) = (std::fs::read(d.join("a.pdf")).unwrap(), std::fs::read(d.join("b.pdf")).unwrap());
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_is_for_pdf_and_png_outputs_only() {
|
||||||
|
let d = dir("other");
|
||||||
|
let mut s = Session::new(Document::from_text("Hello"));
|
||||||
|
for file in ["a.docx", "a.txt", "a.odt", "a.html"] {
|
||||||
|
let path = d.join(file);
|
||||||
|
let r = s.run("file.save", &json!({"path": path, "strict": true}));
|
||||||
|
assert!(matches!(r, Err(CmdError::Params(_))), "{file}: {r:?}");
|
||||||
|
assert!(!path.exists(), "{file}");
|
||||||
|
}
|
||||||
|
let r = s.run("file.exportPng", &json!({"path": d.join("a.jpg"), "strict": true}));
|
||||||
|
assert!(matches!(r, Err(CmdError::Params(_))), "{r:?}");
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_report_has_the_schema_1_shape() {
|
||||||
|
let c = uncovered();
|
||||||
|
let mut s = Session::new(Document::from_text(&format!("a{c}b{c}")));
|
||||||
|
let r = s.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r,
|
||||||
|
json!({
|
||||||
|
"schema": 1,
|
||||||
|
"strict": "failed",
|
||||||
|
"missing_glyphs": [{"cp": format!("U+{:04X}", c as u32), "count": 2}],
|
||||||
|
"missing_pictures": [],
|
||||||
|
"truncated": false,
|
||||||
|
"total_missing_glyphs": 1,
|
||||||
|
"total_missing_pictures": 0,
|
||||||
|
"glyphs_truncated": false,
|
||||||
|
"pictures_truncated": false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let png = s.run("file.strictReport", &json!({"page": 1})).unwrap();
|
||||||
|
assert_eq!(png, r);
|
||||||
|
assert!(matches!(s.run("file.strictReport", &json!({"page": 99})), Err(CmdError::Params(_))));
|
||||||
|
|
||||||
|
let mut ok = Session::new(Document::from_text("fine"));
|
||||||
|
let r = ok.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r,
|
||||||
|
json!({"schema": 1, "strict": "passed", "missing_glyphs": [], "missing_pictures": [], "truncated": false, "total_missing_glyphs": 0, "total_missing_pictures": 0, "glyphs_truncated": false, "pictures_truncated": false})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_report_changes_nothing() {
|
||||||
|
let c = uncovered();
|
||||||
|
let mut s = Session::new(Document::from_text(&format!("a{c}")));
|
||||||
|
let before = serde_json::to_string(&s.doc).unwrap();
|
||||||
|
s.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(serde_json::to_string(&s.doc).unwrap(), before);
|
||||||
|
assert!(!s.dirty);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn garbage_background(d: &mut Document) {
|
||||||
|
d.media.insert("bg.bin".into(), std::sync::Arc::new(b"this is not a picture".to_vec()));
|
||||||
|
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.bin".into(), fill_type: "stretch".into(), title: String::new() });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_undecodable_page_background_picture_is_refused_for_pdf_and_png() {
|
||||||
|
let d = dir("background");
|
||||||
|
let mut doc = Document::from_text("Hello");
|
||||||
|
garbage_background(&mut doc);
|
||||||
|
let mut s = Session::new(doc);
|
||||||
|
let r = s.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(r["strict"], "failed");
|
||||||
|
assert_eq!(r["missing_pictures"][0], json!({"part": "bg.bin", "source_part": "background", "reason": "unsupported_format"}));
|
||||||
|
let msg = refused(s.run("file.exportPdf", &json!({"path": d.join("a.pdf"), "strict": true})));
|
||||||
|
assert!(msg.contains("bg.bin (background)"), "{msg}");
|
||||||
|
refused(s.run("file.exportPng", &json!({"path": d.join("a.png"), "strict": true})));
|
||||||
|
refused(s.run("file.save", &json!({"path": d.join("b.png"), "strict": true})));
|
||||||
|
assert!(!d.join("a.pdf").exists() && !d.join("a.png").exists() && !d.join("b.png").exists());
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decodable_page_background_picture_passes() {
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 200, 255, 255]))
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
let mut doc = Document::from_text("Hello");
|
||||||
|
doc.media.insert("bg.png".into(), std::sync::Arc::new(png));
|
||||||
|
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "stretch".into(), title: String::new() });
|
||||||
|
let mut s = Session::new(doc);
|
||||||
|
assert_eq!(s.run("file.strictReport", &json!({})).unwrap()["strict"], "passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_wider_than_the_pdf_draws_is_refused_for_pdf_only() {
|
||||||
|
// 40000 x 10: past the PDF's decode limit (30000), within what the page raster draws.
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image::RgbaImage::from_pixel(40_000, 10, image::Rgba([9, 9, 9, 255]))
|
||||||
|
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
let mut doc = Document::from_text("Hello");
|
||||||
|
doc.media.insert("wide.png".into(), std::sync::Arc::new(png));
|
||||||
|
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "wide.png".into(), fill_type: "stretch".into(), title: String::new() });
|
||||||
|
let mut s = Session::new(doc);
|
||||||
|
assert_eq!(s.run("file.strictReport", &json!({})).unwrap()["strict"], "failed", "PDF");
|
||||||
|
assert_eq!(s.run("file.strictReport", &json!({"page": 1})).unwrap()["strict"], "passed", "PNG");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watermark(text: &str) -> Document {
|
||||||
|
let mut doc = Document::from_text("Hello");
|
||||||
|
doc.settings.watermark = Some(wordcraft_doc::Watermark { text: text.into(), ..Default::default() });
|
||||||
|
doc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watermark_text_no_font_can_draw_is_refused_and_latin_passes() {
|
||||||
|
let cs = uncovered_many(2);
|
||||||
|
let d = dir("watermark");
|
||||||
|
let mut s = Session::new(watermark(&cs.iter().collect::<String>()));
|
||||||
|
let r = s.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(r["strict"], "failed");
|
||||||
|
let cps: Vec<String> = cs.iter().map(|c| format!("U+{:04X}", *c as u32)).collect();
|
||||||
|
let got: Vec<String> = r["missing_glyphs"].as_array().unwrap().iter().map(|g| g["cp"].as_str().unwrap().to_string()).collect();
|
||||||
|
assert_eq!(got, cps);
|
||||||
|
refused(s.run("file.exportPdf", &json!({"path": d.join("w.pdf"), "strict": true})));
|
||||||
|
refused(s.run("file.exportPng", &json!({"path": d.join("w.png"), "strict": true})));
|
||||||
|
assert!(!d.join("w.pdf").exists() && !d.join("w.png").exists());
|
||||||
|
|
||||||
|
let mut ok = Session::new(watermark("CONFIDENTIAL"));
|
||||||
|
assert_eq!(ok.run("file.strictReport", &json!({})).unwrap()["strict"], "passed");
|
||||||
|
ok.run("file.exportPdf", &json!({"path": d.join("ok.pdf"), "strict": true})).unwrap();
|
||||||
|
ok.run("file.exportPng", &json!({"path": d.join("ok.png"), "strict": true})).unwrap();
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wordart_text_effects_keep_their_glyphs_in_the_check() {
|
||||||
|
let c = uncovered();
|
||||||
|
let props = wordcraft_doc::CharProps { shadow: Some(true), outline: Some(true), ..Default::default() };
|
||||||
|
let mut doc = Document::from_text("");
|
||||||
|
doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("a{c}b"), props))].into_iter().collect();
|
||||||
|
let mut s = Session::new(doc);
|
||||||
|
let r = s.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(r["missing_glyphs"][0]["cp"], format!("U+{:04X}", c as u32), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_list_says_for_itself_that_it_was_cut() {
|
||||||
|
let many = uncovered_many(300);
|
||||||
|
let mut doc = Document::from_text(&many.iter().collect::<String>());
|
||||||
|
garbage_background(&mut doc);
|
||||||
|
let mut s = Session::new(doc);
|
||||||
|
let r = s.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(r["missing_glyphs"].as_array().unwrap().len(), 256);
|
||||||
|
assert_eq!((r["truncated"].clone(), r["glyphs_truncated"].clone(), r["pictures_truncated"].clone()), (json!(true), json!(true), json!(false)));
|
||||||
|
let lines = wordcraft_engine::strict::lines_from_json(&r);
|
||||||
|
assert!(lines[0].ends_with(", …"), "{}", lines[0]);
|
||||||
|
assert!(lines[0].starts_with("strict: 300 characters cannot be drawn (first 256 listed): "), "{}", lines[0]);
|
||||||
|
assert!(!lines[1].contains('…'), "{}", lines[1]);
|
||||||
|
assert_eq!((r["total_missing_glyphs"].clone(), r["total_missing_pictures"].clone()), (json!(300), json!(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_report_carries_totals_past_the_cap() {
|
||||||
|
let many = uncovered_many(300);
|
||||||
|
let mut doc = Document::from_text(&many.iter().collect::<String>());
|
||||||
|
for n in 0..300 {
|
||||||
|
doc.missing_pictures.push(wordcraft_doc::MissingPicture {
|
||||||
|
part: format!("word/media/gone{n}.png"),
|
||||||
|
story: "word/document.xml".into(),
|
||||||
|
reason: wordcraft_doc::MissingPictureReason::MissingPart,
|
||||||
|
});
|
||||||
|
// The same record again is one picture.
|
||||||
|
doc.missing_pictures.push(wordcraft_doc::MissingPicture {
|
||||||
|
part: format!("word/media/gone{n}.png"),
|
||||||
|
story: "word/document.xml".into(),
|
||||||
|
reason: wordcraft_doc::MissingPictureReason::MissingPart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut s = Session::new(doc);
|
||||||
|
let r = s.run("file.strictReport", &json!({})).unwrap();
|
||||||
|
assert_eq!(r["missing_glyphs"].as_array().unwrap().len(), 256);
|
||||||
|
assert_eq!(r["missing_pictures"].as_array().unwrap().len(), 256);
|
||||||
|
assert_eq!(r["truncated"], true);
|
||||||
|
assert_eq!(r["total_missing_glyphs"], 300);
|
||||||
|
assert_eq!(r["total_missing_pictures"], 300);
|
||||||
|
assert_eq!(r["missing_pictures"][0]["reason"], "missing_part");
|
||||||
|
assert_eq!(r["missing_pictures"][0]["source_part"], "word/document.xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_off_gives_the_same_png_as_before_even_with_a_missing_character() {
|
||||||
|
let c = uncovered();
|
||||||
|
let d = dir("pngsame");
|
||||||
|
let mut s = Session::new(Document::from_text(&format!("Hello {c}")));
|
||||||
|
let png: Vec<Vec<u8>> = [json!({}), json!({"strict": false})]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, mut p)| {
|
||||||
|
p["path"] = json!(d.join(format!("{i}.png")));
|
||||||
|
s.run("file.exportPng", &p).unwrap();
|
||||||
|
std::fs::read(d.join(format!("{i}.png"))).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(png[0].starts_with(b"\x89PNG"));
|
||||||
|
assert_eq!(png[0], png[1]);
|
||||||
|
std::fs::remove_dir_all(d).unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//! Pictures a layout draws that the exporters can't decode (#6005): a DOCX opened, laid out, and
|
||||||
|
//! asked which of its pictures would be grey boxes, and in which story.
|
||||||
|
use std::io::Write;
|
||||||
|
use wordcraft_doc::{Document, MissingPictureReason};
|
||||||
|
use wordcraft_layout::display::DisplayOptions;
|
||||||
|
use wordcraft_layout::{LayoutCache, LayoutOptions, StrictOutput, layout, media_check, undecodable_pictures};
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
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 document with one header picture (`header_media`) and one body picture (`body_media`), the
|
||||||
|
/// two parts' bytes as given.
|
||||||
|
fn docx(header_media: &[u8], body_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>"#,
|
||||||
|
pic("rIdB")
|
||||||
|
);
|
||||||
|
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"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/><Relationship Id="rIdB" Type="{REL}/image" Target="media/body.png"/></Relationships>"#
|
||||||
|
);
|
||||||
|
let hrels = format!(
|
||||||
|
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></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 hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
|
||||||
|
for (name, bytes) in [
|
||||||
|
("[Content_Types].xml", types.as_bytes()),
|
||||||
|
("_rels/.rels", rels.as_bytes()),
|
||||||
|
("word/document.xml", doc.as_bytes()),
|
||||||
|
("word/_rels/document.xml.rels", drels.as_bytes()),
|
||||||
|
("word/header2.xml", hdr.as_bytes()),
|
||||||
|
("word/_rels/header2.xml.rels", hrels.as_bytes()),
|
||||||
|
("word/media/image2.bin", header_media),
|
||||||
|
("word/media/body.png", body_media),
|
||||||
|
] {
|
||||||
|
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||||
|
zip.write_all(bytes).unwrap();
|
||||||
|
}
|
||||||
|
zip.finish().unwrap().into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(d: &Document, pages: Option<std::ops::RangeInclusive<usize>>) -> Vec<(String, String, MissingPictureReason)> {
|
||||||
|
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||||
|
let (list, truncated, total) = undecodable_pictures(d, &l, pages, &DisplayOptions::default(), StrictOutput::Pdf);
|
||||||
|
assert!(!truncated);
|
||||||
|
assert_eq!(total, list.len() as u64);
|
||||||
|
list.into_iter().map(|m| (m.part, m.story, m.reason)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_header_picture_of_corrupt_bytes_is_reported_with_its_part_and_story() {
|
||||||
|
let d = wordcraft_docx::read(&docx(b"corrupt bytes, not a picture", &png())).unwrap();
|
||||||
|
assert_eq!(report(&d, None), [("word/media/image2.bin".into(), "word/header2.xml".into(), MissingPictureReason::UnsupportedFormat)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_picture_is_undecodable_not_unsupported() {
|
||||||
|
let mut cut = png();
|
||||||
|
cut.truncate(cut.len() - 20);
|
||||||
|
let d = wordcraft_docx::read(&docx(&png(), &cut)).unwrap();
|
||||||
|
assert_eq!(report(&d, None), [("word/media/body.png".into(), "word/document.xml".into(), MissingPictureReason::Undecodable)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_real_png_in_a_header_is_not_reported() {
|
||||||
|
let d = wordcraft_docx::read(&docx(&png(), &png())).unwrap();
|
||||||
|
assert_eq!(report(&d, None), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_selected_pages_are_checked() {
|
||||||
|
let d = wordcraft_docx::read(&docx(b"corrupt", &png())).unwrap();
|
||||||
|
assert_eq!(report(&d, Some(0..=0)).len(), 1);
|
||||||
|
assert_eq!(report(&d, Some(5..=9)), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_check_names_what_the_exporters_draw() {
|
||||||
|
assert_eq!(media_check(&png(), StrictOutput::Pdf), Ok(()));
|
||||||
|
assert!(media_check(b"", StrictOutput::Pdf).is_err());
|
||||||
|
assert!(media_check(&[1, 0, 0, 0, 88, 0, 0, 0], StrictOutput::Pdf).is_err(), "a metafile that doesn't parse is a grey box");
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Opt-in test harness support; enabling this alone never changes the application database.
|
||||||
|
test-support = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kurbo = { workspace = true }
|
kurbo = { workspace = true }
|
||||||
skrifa = { workspace = true }
|
skrifa = { workspace = true }
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use std::sync::{Arc, Mutex, RwLock};
|
|||||||
|
|
||||||
use kurbo::BezPath;
|
use kurbo::BezPath;
|
||||||
use skrifa::instance::{Location, LocationRef, Size};
|
use skrifa::instance::{Location, LocationRef, Size};
|
||||||
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
|
use skrifa::outline::{
|
||||||
|
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
|
||||||
|
};
|
||||||
use skrifa::raw::FileRef;
|
use skrifa::raw::FileRef;
|
||||||
use skrifa::string::StringId;
|
use skrifa::string::StringId;
|
||||||
use skrifa::{GlyphId, MetadataProvider};
|
use skrifa::{GlyphId, MetadataProvider};
|
||||||
@@ -39,10 +41,16 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use names::{last_resort_face, legacy_family};
|
pub(crate) use names::{last_resort_face, legacy_family};
|
||||||
|
|
||||||
|
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
/// Process-wide font database.
|
/// Process-wide font database.
|
||||||
pub struct FontDb {
|
pub struct FontDb {
|
||||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||||
|
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
|
||||||
|
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
|
||||||
|
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
|
||||||
|
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
|
||||||
/// Fallback faces found per (character, excluded face), valid while the face count is the
|
/// Fallback faces found per (character, excluded face), valid while the face count is the
|
||||||
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
|
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
|
||||||
/// search sorts every loaded face, so without this text in a script the requested font lacks
|
/// search sorts every loaded face, so without this text in a script the requested font lacks
|
||||||
@@ -90,10 +98,14 @@ impl FontDb {
|
|||||||
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
|
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
|
||||||
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||||
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
||||||
let mut faces = Vec::new();
|
|
||||||
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
|
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
|
||||||
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
|
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
|
||||||
for data in BUNDLED.iter().copied().chain(craft) {
|
Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_static_fonts(font_dirs: Vec<std::path::PathBuf>, fonts: impl Iterator<Item = &'static [u8]>) -> Self {
|
||||||
|
let mut faces = Vec::new();
|
||||||
|
for data in fonts {
|
||||||
for f in enumerate_faces(data, MAX_FILE_FACES) {
|
for f in enumerate_faces(data, MAX_FILE_FACES) {
|
||||||
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||||
face.local_names = f.local;
|
face.local_names = f.local;
|
||||||
@@ -104,6 +116,8 @@ impl FontDb {
|
|||||||
Self {
|
Self {
|
||||||
faces: RwLock::new(faces),
|
faces: RwLock::new(faces),
|
||||||
outlines: Mutex::new(HashMap::new()),
|
outlines: Mutex::new(HashMap::new()),
|
||||||
|
hinted: Mutex::new(HashMap::new()),
|
||||||
|
hinters: Mutex::new(HashMap::new()),
|
||||||
fallbacks: RwLock::new(FallbackCache::default()),
|
fallbacks: RwLock::new(FallbackCache::default()),
|
||||||
run_fallbacks: RwLock::new(RunFallbackCache::default()),
|
run_fallbacks: RwLock::new(RunFallbackCache::default()),
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -144,8 +158,26 @@ impl FontDb {
|
|||||||
/// first time a lookup by family name needs them (or ahead of time by
|
/// first time a lookup by family name needs them (or ahead of time by
|
||||||
/// [`FontDb::scan_in_background`]).
|
/// [`FontDb::scan_in_background`]).
|
||||||
pub fn global() -> &'static FontDb {
|
pub fn global() -> &'static FontDb {
|
||||||
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
}
|
||||||
|
|
||||||
|
/// Initialize a test process with only the repository's pinned bundled fonts: no optional
|
||||||
|
/// craft-fonts, user fonts or system font directories. Call before any global font lookup.
|
||||||
|
/// Returns false if a normal global database was already initialized; never replaces it.
|
||||||
|
/// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
pub fn init_test_fonts() -> bool {
|
||||||
|
static INIT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*INIT.get_or_init(|| {
|
||||||
|
let mut installed = false;
|
||||||
|
GLOBAL.get_or_init(|| {
|
||||||
|
installed = true;
|
||||||
|
let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied());
|
||||||
|
db.set_system_fallback(false);
|
||||||
|
db
|
||||||
|
});
|
||||||
|
installed
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::names::{LocalName, east_asian_font};
|
use super::names::{LocalName, east_asian_font};
|
||||||
use super::{
|
use super::{
|
||||||
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
|
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
|
||||||
Ordering, OutlinePen, RwLock, Size, Target,
|
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The family used when a requested family is unknown (the UI sans).
|
/// The family used when a requested family is unknown (the UI sans).
|
||||||
@@ -77,6 +77,8 @@ pub struct FontFace {
|
|||||||
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
|
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||||
// Some legacy CJK fonts assemble their contours with TrueType instructions.
|
// Some legacy CJK fonts assemble their contours with TrueType instructions.
|
||||||
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
|
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
|
||||||
|
/// The autohinter's analysis of the font for screen hinting, made once for every size.
|
||||||
|
pub(super) glyph_styles: std::sync::OnceLock<GlyphStyles>,
|
||||||
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
|
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
|
||||||
/// ([`FontFace::count_hinted`]).
|
/// ([`FontFace::count_hinted`]).
|
||||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||||
@@ -288,6 +290,42 @@ impl FontFace {
|
|||||||
})
|
})
|
||||||
.as_ref()
|
.as_ref()
|
||||||
}
|
}
|
||||||
|
/// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device
|
||||||
|
/// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light,
|
||||||
|
/// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto
|
||||||
|
/// whole pixels, nothing moves sideways, so advances and layout stay as they are.
|
||||||
|
pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option<BezPath> {
|
||||||
|
let f = self.skrifa()?;
|
||||||
|
let g = f.outline_glyphs().get(GlyphId::new(gid))?;
|
||||||
|
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
|
||||||
|
g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?;
|
||||||
|
Some(pen.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light
|
||||||
|
/// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored;
|
||||||
|
/// measured against Firefox on Linux, the autohinter matches its rows of pixels and the
|
||||||
|
/// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for
|
||||||
|
/// fonts whose glyphs need their instructions to be assembled at all their instructions with
|
||||||
|
/// the monochrome target, as skrifa requires for them.
|
||||||
|
pub(super) fn screen_hinter(&self, ppem: f32) -> Option<HintingInstance> {
|
||||||
|
let f = self.skrifa()?;
|
||||||
|
let outlines = f.outline_glyphs();
|
||||||
|
let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
|
||||||
|
let (engine, target) = if outlines.require_interpreter() {
|
||||||
|
// FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their
|
||||||
|
// instructions): skrifa asks for the interpreter with the monochrome target, under
|
||||||
|
// which the instructions may move points sideways, or glyphs come apart.
|
||||||
|
(Engine::Interpreter, Target::Mono)
|
||||||
|
} else if outlines.format() == Some(OutlineGlyphFormat::Glyf) {
|
||||||
|
(Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light)
|
||||||
|
} else {
|
||||||
|
(Engine::Interpreter, light)
|
||||||
|
};
|
||||||
|
HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target })
|
||||||
|
.map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
/// Is `family` this face's typographic, legacy, full or PostScript name?
|
/// Is `family` this face's typographic, legacy, full or PostScript name?
|
||||||
pub fn named(&self, family: &str) -> bool {
|
pub fn named(&self, family: &str) -> bool {
|
||||||
self.family.eq_ignore_ascii_case(family)
|
self.family.eq_ignore_ascii_case(family)
|
||||||
@@ -358,13 +396,18 @@ impl FontFace {
|
|||||||
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||||
|
|
||||||
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
|
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||||
|
/// Hinting instances kept (one per face and text size on screen).
|
||||||
|
pub(super) const HINTER_CACHE_MAX: usize = 256;
|
||||||
|
/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the
|
||||||
|
/// half-pixel hinting moves is invisible (and each size would cost a cache entry).
|
||||||
|
pub(super) const HINT_PPEM: std::ops::RangeInclusive<f32> = 4.0..=96.0;
|
||||||
|
|
||||||
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
|
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
|
||||||
/// had to drop one).
|
/// had to drop one).
|
||||||
pub(super) struct FlipPen {
|
pub(super) struct FlipPen {
|
||||||
pub(super) path: BezPath,
|
pub(super) path: BezPath,
|
||||||
room: usize,
|
pub(super) room: usize,
|
||||||
over: bool,
|
pub(super) over: bool,
|
||||||
/// Drawn with the face's TrueType interpreter.
|
/// Drawn with the face's TrueType interpreter.
|
||||||
pub(super) hinted: bool,
|
pub(super) hinted: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
|
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use super::fallback_data::read_fallback_file;
|
use super::fallback_data::read_fallback_file;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -227,4 +227,41 @@ impl FontDb {
|
|||||||
cache.insert(key, p.clone());
|
cache.insert(key, p.clone());
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in
|
||||||
|
/// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph,
|
||||||
|
/// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`].
|
||||||
|
pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option<Arc<BezPath>> {
|
||||||
|
if !HINT_PPEM.contains(&ppem) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key = (face.id, gid, ppem.to_bits());
|
||||||
|
if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||||
|
return p.clone();
|
||||||
|
}
|
||||||
|
let hinter = {
|
||||||
|
let hkey = (face.id, ppem.to_bits());
|
||||||
|
let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned();
|
||||||
|
match known {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
// Built outside the lock: running a font's hinting programs takes a while.
|
||||||
|
let h = face.screen_hinter(ppem).map(Arc::new);
|
||||||
|
let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if cache.len() >= HINTER_CACHE_MAX {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
cache.insert(hkey, h.clone());
|
||||||
|
h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new);
|
||||||
|
let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
cache.insert(key, p.clone());
|
||||||
|
p
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ pub(super) fn make_face(
|
|||||||
index,
|
index,
|
||||||
bmp: std::sync::OnceLock::new(),
|
bmp: std::sync::OnceLock::new(),
|
||||||
outline_hinter: std::sync::OnceLock::new(),
|
outline_hinter: std::sync::OnceLock::new(),
|
||||||
|
glyph_styles: std::sync::OnceLock::new(),
|
||||||
hint_draws: Default::default(),
|
hint_draws: Default::default(),
|
||||||
hint_slow: Default::default(),
|
hint_slow: Default::default(),
|
||||||
hinting_off: Default::default(),
|
hinting_off: Default::default(),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ fn kai_contours_include_complete_character_body() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use super::face::FlipPen;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -92,6 +93,67 @@ fn run_fallback_is_cached_per_char_set() {
|
|||||||
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
|
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() {
|
||||||
|
let db = FontDb::global();
|
||||||
|
let face = db.face(FALLBACK_FAMILY, "Regular");
|
||||||
|
let x = face.glyph_for('x');
|
||||||
|
// 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens.
|
||||||
|
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
|
||||||
|
let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted");
|
||||||
|
let plain = db.outline(&face, x);
|
||||||
|
let k = f64::from(ppem) / face.upem;
|
||||||
|
let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain));
|
||||||
|
// The x-height (the top, y-down) and the baseline sit on the pixel grid…
|
||||||
|
assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0);
|
||||||
|
assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1);
|
||||||
|
assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k);
|
||||||
|
// …and the sides stay where the font puts them, so advances and line breaks don't change.
|
||||||
|
assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k));
|
||||||
|
// Cached per size.
|
||||||
|
assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() {
|
||||||
|
// FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on
|
||||||
|
// TrueType outlines and ignores their instructions: measured against Firefox, the
|
||||||
|
// instructions drew Arial's rows elsewhere and heavier.
|
||||||
|
let db = FontDb::global();
|
||||||
|
let face = db.face(FALLBACK_FAMILY, "Regular");
|
||||||
|
let font = face.skrifa().unwrap();
|
||||||
|
let outlines = font.outline_glyphs();
|
||||||
|
assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)");
|
||||||
|
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
|
||||||
|
let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
|
||||||
|
let drawn = |engine: Engine, gid: u32| {
|
||||||
|
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap();
|
||||||
|
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
|
||||||
|
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
|
||||||
|
pen.path
|
||||||
|
};
|
||||||
|
let mut differs = false;
|
||||||
|
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
|
||||||
|
let gid = face.glyph_for(c);
|
||||||
|
let ours = db.hinted_outline(&face, gid, ppem).unwrap();
|
||||||
|
assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline");
|
||||||
|
differs |= *ours != drawn(Engine::Interpreter, gid);
|
||||||
|
}
|
||||||
|
assert!(differs, "the font's own instructions would draw it differently");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_hinting_refuses_hostile_sizes() {
|
||||||
|
let db = FontDb::global();
|
||||||
|
let face = db.face(FALLBACK_FAMILY, "Regular");
|
||||||
|
let x = face.glyph_for('x');
|
||||||
|
for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] {
|
||||||
|
assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}");
|
||||||
|
}
|
||||||
|
// A glyph id past the font's glyphs has no outline to hint.
|
||||||
|
assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
|
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
|
||||||
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::fontdb::face::FlipPen;
|
||||||
use crate::fontdb::*;
|
use crate::fontdb::*;
|
||||||
|
|
||||||
const FAMILY: &str = "Sysfont Sans3";
|
const FAMILY: &str = "Sysfont Sans3";
|
||||||
@@ -420,6 +421,39 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() {
|
|||||||
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
|
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() {
|
||||||
|
// FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble
|
||||||
|
// their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono`
|
||||||
|
// for them. A bundled font renamed into the list stands in for one.
|
||||||
|
let dir = font_dir("tricky");
|
||||||
|
std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap();
|
||||||
|
let db = FontDb::with_font_dirs(vec![dir]);
|
||||||
|
let face = db.face("Sysfont cpop1", "Regular");
|
||||||
|
assert_eq!(face.family, "Sysfont cpop1");
|
||||||
|
let font = face.skrifa().unwrap();
|
||||||
|
let outlines = font.outline_glyphs();
|
||||||
|
assert!(outlines.require_interpreter(), "the name makes it tricky");
|
||||||
|
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
|
||||||
|
let drawn = |target: Target, gid: u32| {
|
||||||
|
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap();
|
||||||
|
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
|
||||||
|
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
|
||||||
|
pen.path
|
||||||
|
};
|
||||||
|
// The target decides whether the instructions may move points sideways (the light target's
|
||||||
|
// backward compatibility forbids it): this font's instructions only move points vertically,
|
||||||
|
// so the target itself is checked, and the outlines drawn with it.
|
||||||
|
assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono));
|
||||||
|
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
|
||||||
|
let gid = face.glyph_for(c);
|
||||||
|
assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target");
|
||||||
|
}
|
||||||
|
// Any other font keeps the light target.
|
||||||
|
let plain = db.face(FALLBACK_FAMILY, "Regular");
|
||||||
|
assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. }));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn coverage_ranges_hold_exactly_the_mapped_characters() {
|
fn coverage_ranges_hold_exactly_the_mapped_characters() {
|
||||||
let data = renamed("SourceSans3-Regular.ttf");
|
let data = renamed("SourceSans3-Regular.ttf");
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Separate process: verifies the global test fixture before any other lookup initializes it.
|
||||||
|
#![cfg(feature = "test-support")]
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use wordcraft_fonts::FontDb;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_fonts_exclude_system_and_optional_faces() {
|
||||||
|
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
|
||||||
|
assert!(threads.into_iter().all(|t| t.join().unwrap()));
|
||||||
|
let db = FontDb::global();
|
||||||
|
let families = [
|
||||||
|
"Inter",
|
||||||
|
"Inter Medium",
|
||||||
|
"Inter SemiBold",
|
||||||
|
"JetBrains Mono",
|
||||||
|
"Source Sans 3",
|
||||||
|
"Source Sans 3 Semibold",
|
||||||
|
"Source Serif 4",
|
||||||
|
"Source Serif 4 Semibold",
|
||||||
|
];
|
||||||
|
assert_eq!(db.families(), families);
|
||||||
|
assert_eq!(db.load_system_fonts(), 0);
|
||||||
|
assert_eq!(db.families(), families);
|
||||||
|
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
|
||||||
|
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
|
||||||
|
}
|
||||||
|
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
|
||||||
|
assert_eq!(db.loaded_font_bytes(), 0);
|
||||||
|
assert!(FontDb::init_test_fonts());
|
||||||
|
}
|
||||||
@@ -19,3 +19,11 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
|
|||||||
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
|
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
|
||||||
assert!(FontDb::global().families().contains(family));
|
assert!(FontDb::global().families().contains(family));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
#[test]
|
||||||
|
fn test_initialization_cannot_replace_an_application_database() {
|
||||||
|
let db = FontDb::global();
|
||||||
|
assert!(!FontDb::init_test_fonts());
|
||||||
|
assert!(std::ptr::eq(db, FontDb::global()));
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,13 +123,15 @@ mod tests {
|
|||||||
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
|
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
|
||||||
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
|
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
|
||||||
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
|
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
|
||||||
assert_eq!(parse_color("#0080ff80"), Some(Rgb(0, 128, 255)));
|
assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255)));
|
||||||
|
// A colour with alpha is composited over the white page it lands on (#0080ff at 50%).
|
||||||
|
assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255)));
|
||||||
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
|
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
|
||||||
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
|
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
|
||||||
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(1, 2, 3)));
|
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
|
||||||
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
|
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
|
||||||
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
|
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
|
||||||
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(0, 0, 255)));
|
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
|
||||||
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
|
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
|
||||||
assert_eq!(parse_color("#zz"), None);
|
assert_eq!(parse_color("#zz"), None);
|
||||||
assert_eq!(parse_color("rgb(1)"), None);
|
assert_eq!(parse_color("rgb(1)"), None);
|
||||||
|
|||||||
@@ -34,24 +34,59 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
|
|||||||
Rgb(r, g, b)
|
Rgb(r, g, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0,
|
||||||
|
/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black.
|
||||||
|
fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb {
|
||||||
|
let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
|
||||||
|
Rgb(mix(r), mix(g), mix(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise.
|
||||||
|
fn css_alpha(after: Option<&str>) -> f32 {
|
||||||
|
let Some(a) = after else { return 1.0 };
|
||||||
|
let a = a.trim();
|
||||||
|
// CSS Colour 4's `none` is a missing component, and a missing alpha is transparent.
|
||||||
|
if a.eq_ignore_ascii_case("none") {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
// A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep
|
||||||
|
// NaN, and `over_white` would turn the colour black.
|
||||||
|
let parse = |t: &str| t.parse::<f32>().ok().filter(|f| f.is_finite());
|
||||||
|
if let Some(p) = a.strip_suffix('%') {
|
||||||
|
return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0);
|
||||||
|
}
|
||||||
|
parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
|
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
|
||||||
|
/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands
|
||||||
|
/// on a white page.
|
||||||
pub fn parse_color(s: &str) -> Option<Rgb> {
|
pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||||
let s = s.trim().to_ascii_lowercase();
|
let s = s.trim().to_ascii_lowercase();
|
||||||
if let Some(h) = s.strip_prefix('#') {
|
if let Some(h) = s.strip_prefix('#') {
|
||||||
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
|
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
|
||||||
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
|
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
|
||||||
return Some(Rgb(d(0)?, d(1)?, d(2)?));
|
let (r, g, b) = (d(0)?, d(1)?, d(2)?);
|
||||||
|
// A 4th digit is the alpha, composited over the page like `rgba(...)`'s is.
|
||||||
|
let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 };
|
||||||
|
return Some(over_white(r, g, b, a));
|
||||||
}
|
}
|
||||||
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||||
return Some(Rgb(p(0)?, p(2)?, p(4)?));
|
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
|
||||||
}
|
}
|
||||||
return Rgb::parse(h);
|
return Rgb::parse(h);
|
||||||
}
|
}
|
||||||
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
|
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
|
||||||
let inner = inner.trim_end_matches(')').trim();
|
let inner = inner.trim_end_matches(')').trim();
|
||||||
let before_slash = inner.split('/').next().unwrap_or(inner);
|
let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
|
||||||
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
|
// Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`).
|
||||||
|
let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() };
|
||||||
|
let alpha = match alpha_part {
|
||||||
|
Some(a) => css_alpha(Some(a)),
|
||||||
|
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
let v: Vec<u8> = parts
|
let v: Vec<u8> = parts
|
||||||
.iter()
|
.iter()
|
||||||
.take(3)
|
.take(3)
|
||||||
@@ -65,14 +100,19 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if let [r, g, b] = v[..] {
|
if let [r, g, b] = v[..] {
|
||||||
return Some(Rgb(r, g, b));
|
return Some(over_white(r, g, b, alpha));
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
|
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
|
||||||
let inner = inner.trim_end_matches(')').trim();
|
let inner = inner.trim_end_matches(')').trim();
|
||||||
let before_slash = inner.split('/').next().unwrap_or(inner);
|
let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
|
||||||
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
|
let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() };
|
||||||
|
let alpha = match alpha_part {
|
||||||
|
Some(a) => css_alpha(Some(a)),
|
||||||
|
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
if parts.len() < 3 {
|
if parts.len() < 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -96,7 +136,8 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
|||||||
};
|
};
|
||||||
let s = parse_pct(parts[1])?;
|
let s = parse_pct(parts[1])?;
|
||||||
let l = parse_pct(parts[2])?;
|
let l = parse_pct(parts[2])?;
|
||||||
return Some(hsl_to_rgb(h, s, l));
|
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
|
||||||
|
return Some(over_white(r, g, b, alpha));
|
||||||
}
|
}
|
||||||
Some(match s.as_str() {
|
Some(match s.as_str() {
|
||||||
"black" => Rgb(0, 0, 0),
|
"black" => Rgb(0, 0, 0),
|
||||||
@@ -127,6 +168,29 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn eight_digit_hex_rejects_signs() {
|
fn eight_digit_hex_rejects_signs() {
|
||||||
assert_eq!(parse_color("#+1+2+3+4"), None);
|
assert_eq!(parse_color("#+1+2+3+4"), None);
|
||||||
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
|
// #11223344 is alpha 0x44 over white.
|
||||||
|
assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colors_with_alpha_composite_over_white() {
|
||||||
|
// The secondary text colour of dark-theme sites: solid black would read as the site's
|
||||||
|
// own emphasis, white is invisible on the page; the composite is what the eye saw.
|
||||||
|
assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140)));
|
||||||
|
assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140)));
|
||||||
|
assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255)));
|
||||||
|
assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0)));
|
||||||
|
assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140)));
|
||||||
|
assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128)));
|
||||||
|
// A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white.
|
||||||
|
assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119)));
|
||||||
|
// `none` is a missing alpha, and a missing alpha is transparent, not the opaque default
|
||||||
|
// an omitted alpha takes.
|
||||||
|
assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255)));
|
||||||
|
assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0)));
|
||||||
|
// An alpha that is not a finite number is no alpha: the colour stays opaque, not black.
|
||||||
|
assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0)));
|
||||||
|
assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0)));
|
||||||
|
assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
|
|||||||
/// The flow formatting of a run with properties `props`: with `opts.chars`, its extra formatting,
|
/// The flow formatting of a run with properties `props`: with `opts.chars`, its extra formatting,
|
||||||
/// its custom character style by name (only its direct formatting then its own) and a
|
/// its custom character style by name (only its direct formatting then its own) and a
|
||||||
/// monospaced font by its name.
|
/// monospaced font by its name.
|
||||||
fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
|
fn run_fmt(doc: &Document, para_style: Option<&str>, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
|
||||||
let mut f = fmt_of(doc, props, in_code);
|
let mut f = fmt_of(doc, props, in_code);
|
||||||
if !opts.chars {
|
if !opts.chars {
|
||||||
return f;
|
return f;
|
||||||
@@ -104,6 +104,16 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
|
|||||||
if let Some(id) = custom {
|
if let Some(id) = custom {
|
||||||
f.extra.get_or_insert_with(Default::default).style = Some(id.to_string());
|
f.extra.get_or_insert_with(Default::default).style = Some(id.to_string());
|
||||||
}
|
}
|
||||||
|
// A size stated without a complex-script size leaves the complex size to what is above it
|
||||||
|
// (a style's, or this size where none says one): said where it isn't this size, since a
|
||||||
|
// writer that gives every script the one size would otherwise change it.
|
||||||
|
let sized = props.size.is_some() || custom.is_some_and(|id| doc.styles.chain(id).iter().any(|s| s.chr.size.is_some()));
|
||||||
|
if props.size_cs.is_none() && sized && f.extra.as_ref().is_none_or(|x| x.size_cs.is_none()) {
|
||||||
|
let cs = doc.styles.resolve_char(para_style, props).size_cs;
|
||||||
|
if f.size != Some(cs) {
|
||||||
|
f.extra.get_or_insert_with(Default::default).size_cs = Some(cs);
|
||||||
|
}
|
||||||
|
}
|
||||||
if f.code {
|
if f.code {
|
||||||
f.code = false;
|
f.code = false;
|
||||||
f.font = e.shown_font().map(Arc::from);
|
f.font = e.shown_font().map(Arc::from);
|
||||||
@@ -116,11 +126,15 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
|
|||||||
fn extra_of(e: &CharProps) -> Option<Box<CharProps>> {
|
fn extra_of(e: &CharProps) -> Option<Box<CharProps>> {
|
||||||
let set = |v: Option<bool>| v.filter(|b| *b);
|
let set = |v: Option<bool>| v.filter(|b| *b);
|
||||||
let x = CharProps {
|
let x = CharProps {
|
||||||
underline: e.underline.filter(|u| !matches!(u, Underline::None | Underline::Single)),
|
// What it turns off, over what the style of its paragraph or span turns on, too.
|
||||||
|
bold: e.bold.filter(|b| !*b),
|
||||||
|
italic: e.italic.filter(|b| !*b),
|
||||||
|
strike: e.strike.filter(|b| !*b),
|
||||||
|
underline: e.underline.filter(|u| *u != Underline::Single),
|
||||||
underline_color: e.underline_color,
|
underline_color: e.underline_color,
|
||||||
double_strike: set(e.double_strike),
|
double_strike: set(e.double_strike),
|
||||||
caps: set(e.caps),
|
caps: e.caps,
|
||||||
small_caps: set(e.small_caps),
|
small_caps: e.small_caps,
|
||||||
hidden: set(e.hidden),
|
hidden: set(e.hidden),
|
||||||
spacing: e.spacing.filter(|v| *v != 0.0 && v.is_finite()),
|
spacing: e.spacing.filter(|v| *v != 0.0 && v.is_finite()),
|
||||||
scale: e.scale.filter(|v| *v != 100.0 && v.is_finite()),
|
scale: e.scale.filter(|v| *v != 100.0 && v.is_finite()),
|
||||||
@@ -182,6 +196,9 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
|
|||||||
tabs
|
tabs
|
||||||
});
|
});
|
||||||
let style = p.style.clone().filter(|id| is_custom_style(doc, id));
|
let style = p.style.clone().filter(|id| is_custom_style(doc, id));
|
||||||
|
// Its own, else its custom style's: the kind doesn't say (it stops at Heading 6, and is the
|
||||||
|
// heading style's where the style is based on one).
|
||||||
|
let outline_level = p.outline_level.or_else(|| style.as_ref().and_then(|_| doc.styles.resolve_para(p).outline_level));
|
||||||
let x = wordcraft_doc::props::ParaProps {
|
let x = wordcraft_doc::props::ParaProps {
|
||||||
tabs,
|
tabs,
|
||||||
shading: p.shading,
|
shading: p.shading,
|
||||||
@@ -191,7 +208,7 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
|
|||||||
drop_cap: p.drop_cap.filter(|l| *l > 1),
|
drop_cap: p.drop_cap.filter(|l| *l > 1),
|
||||||
drop_cap_distance: p.drop_cap_distance.filter(|_| p.drop_cap.is_some_and(|l| l > 1)),
|
drop_cap_distance: p.drop_cap_distance.filter(|_| p.drop_cap.is_some_and(|l| l > 1)),
|
||||||
keep_lines: p.keep_lines.filter(|k| *k),
|
keep_lines: p.keep_lines.filter(|k| *k),
|
||||||
outline_level: p.outline_level,
|
outline_level,
|
||||||
widow_control: p.widow_control.filter(|w| !*w),
|
widow_control: p.widow_control.filter(|w| !*w),
|
||||||
// Hyphenated (the document hyphenates and the paragraph doesn't opt out): `Some(false)`.
|
// Hyphenated (the document hyphenates and the paragraph doesn't opt out): `Some(false)`.
|
||||||
suppress_hyphens: (doc.settings.auto_hyphenation && p.suppress_hyphens != Some(true)).then_some(false),
|
suppress_hyphens: (doc.settings.auto_hyphenation && p.suppress_hyphens != Some(true)).then_some(false),
|
||||||
@@ -413,14 +430,25 @@ fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option<u32>,
|
|||||||
FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks }
|
FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `li`, the list of heading `p`, is the number its outline level has from its heading
|
||||||
|
/// style's list, not a list of its own: it states that level, and the heading style of that level
|
||||||
|
/// numbers with that list there. Past Heading 6 no such style gives the written outline a number.
|
||||||
|
fn numbered_by_outline(doc: &Document, p: &Paragraph, li: &ListInfo) -> bool {
|
||||||
|
use wordcraft_doc::props::NumRef;
|
||||||
|
p.props.outline_level == Some(li.level)
|
||||||
|
&& li.level < 6
|
||||||
|
&& doc.styles.para_style_props(&format!("Heading{}", li.level + 1)).0.numbering == Some(NumRef { num: li.list, level: li.level })
|
||||||
|
}
|
||||||
|
|
||||||
/// A paragraph's list membership as the flow keeps it: without the list's identity and label
|
/// A paragraph's list membership as the flow keeps it: without the list's identity and label
|
||||||
/// unless `opts` asks for them, and none for a heading numbered only through its style, which a
|
/// unless `opts` asks for them, and none for a heading numbered only through its style or
|
||||||
/// format with heading numbering of its own (ODT's outline style) numbers instead.
|
/// [`numbered_by_outline`], which a format with heading numbering of its own (ODT's outline style)
|
||||||
fn flow_list(kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
|
/// numbers instead, in its first six levels.
|
||||||
|
fn flow_list(doc: &Document, kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
|
||||||
let mut li = list?;
|
let mut li = list?;
|
||||||
if !opts.lists {
|
if !opts.lists {
|
||||||
(li.list, li.label) = (0, None);
|
(li.list, li.label) = (0, None);
|
||||||
} else if matches!(kind, Kind::Heading(_)) && p.props.numbering.is_none() {
|
} else if matches!(kind, Kind::Heading(_)) && li.level < 6 && (p.props.numbering.is_none() || numbered_by_outline(doc, p, &li)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(li)
|
Some(li)
|
||||||
@@ -458,7 +486,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
|||||||
fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||||
let tracked = opts.tracked;
|
let tracked = opts.tracked;
|
||||||
let (kind, list) = para_kind(doc, p);
|
let (kind, list) = para_kind(doc, p);
|
||||||
let list = flow_list(kind, list, p, opts);
|
let list = flow_list(doc, kind, list, p, opts);
|
||||||
let mut done: Vec<Para> = Vec::new();
|
let mut done: Vec<Para> = Vec::new();
|
||||||
let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.takes_room()) };
|
let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.takes_room()) };
|
||||||
let mut out = Para {
|
let mut out = Para {
|
||||||
@@ -494,7 +522,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
|||||||
// Formats that keep hidden text get it, marked hidden.
|
// Formats that keep hidden text get it, marked hidden.
|
||||||
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
|
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
|
||||||
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
|
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
|
||||||
let mut f = run_fmt(doc, props, in_code, opts);
|
let mut f = run_fmt(doc, p.props.style.as_deref(), props, in_code, opts);
|
||||||
if tracked {
|
if tracked {
|
||||||
(f.ins, f.del) = rev_marks(doc, props);
|
(f.ins, f.del) = rev_marks(doc, props);
|
||||||
f.fmt_change = props.fmt_change.as_deref().map(|c| Box::new(FmtChange { mark: rev_mark(doc, c.rev), old: fmt_of(doc, &c.old, in_code) }));
|
f.fmt_change = props.fmt_change.as_deref().map(|c| Box::new(FmtChange { mark: rev_mark(doc, c.rev), old: fmt_of(doc, &c.old, in_code) }));
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ impl WithExtra for CharProps {
|
|||||||
if f.underline {
|
if f.underline {
|
||||||
self.underline_color = x.underline_color;
|
self.underline_color = x.underline_color;
|
||||||
}
|
}
|
||||||
|
// Turned off on the text, over what the style of its paragraph or span turns on.
|
||||||
|
if !f.bold && x.bold == Some(false) {
|
||||||
|
self.bold = Some(false);
|
||||||
|
}
|
||||||
|
if !f.italic && x.italic == Some(false) {
|
||||||
|
self.italic = Some(false);
|
||||||
|
}
|
||||||
|
if !f.strike && x.strike == Some(false) {
|
||||||
|
// A double strike under it goes too.
|
||||||
|
(self.strike, self.double_strike) = (Some(false), Some(false));
|
||||||
|
}
|
||||||
|
if !f.underline && x.underline == Some(Underline::None) {
|
||||||
|
self.underline = Some(Underline::None);
|
||||||
|
}
|
||||||
if x.double_strike == Some(true) {
|
if x.double_strike == Some(true) {
|
||||||
(self.strike, self.double_strike) = (None, Some(true));
|
(self.strike, self.double_strike) = (None, Some(true));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1259,6 +1259,7 @@ impl Body<'_> {
|
|||||||
if self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc {
|
if self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc {
|
||||||
self.group = Some(OpenGroup {
|
self.group = Some(OpenGroup {
|
||||||
anchor: get(a, "text:anchor-type").map(String::from),
|
anchor: get(a, "text:anchor-type").map(String::from),
|
||||||
|
page: get(a, "text:anchor-page-number").map(String::from),
|
||||||
style: get(a, "draw:style-name").map(String::from),
|
style: get(a, "draw:style-name").map(String::from),
|
||||||
members: Vec::new(),
|
members: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -1300,10 +1301,14 @@ impl Body<'_> {
|
|||||||
}
|
}
|
||||||
"table-column" => {
|
"table-column" => {
|
||||||
let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0);
|
let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0);
|
||||||
|
let share = self.styles.column_share(get(a, "table:style-name")).unwrap_or(0.0);
|
||||||
|
let cells = get(a, "table:default-cell-style-name").and_then(|s| self.styles.cell_background(s));
|
||||||
let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).max(1);
|
let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).max(1);
|
||||||
if let Some(t) = self.tables.last_mut() {
|
if let Some(t) = self.tables.last_mut() {
|
||||||
let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len());
|
let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len());
|
||||||
t.widths.extend(std::iter::repeat_n(w, n.min(room)));
|
t.widths.extend(std::iter::repeat_n(w, n.min(room)));
|
||||||
|
t.shares.extend(std::iter::repeat_n(share, n.min(room)));
|
||||||
|
t.column_backgrounds.extend(std::iter::repeat_n(cells, n.min(room)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"table-row" => self.start_row(a),
|
"table-row" => self.start_row(a),
|
||||||
@@ -1311,7 +1316,16 @@ impl Body<'_> {
|
|||||||
self.end_para();
|
self.end_para();
|
||||||
let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
|
let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
|
||||||
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
|
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
|
||||||
let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s));
|
// WordCraft's tables have their backgrounds on their cells: a row's or table's on
|
||||||
|
// those with none of their own, as it shows behind them. A cell with no style has
|
||||||
|
// its column's default cell style; LibreOffice doesn't apply a row's.
|
||||||
|
let behind = self.tables.last().and_then(|t| t.row_background.or(t.background));
|
||||||
|
let in_column = self.tables.last().and_then(|t| {
|
||||||
|
let at: usize = t.row.iter().map(|c| c.colspan.max(1) as usize).sum();
|
||||||
|
t.column_backgrounds.get(at).copied().flatten()
|
||||||
|
});
|
||||||
|
let own = get(a, "table:style-name").map_or(in_column, |s| self.styles.cell_background(s));
|
||||||
|
let shading = own.or(behind);
|
||||||
let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s));
|
let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s));
|
||||||
let borders = get(a, "table:style-name").and_then(|s| self.styles.cell_borders(s));
|
let borders = get(a, "table:style-name").and_then(|s| self.styles.cell_borders(s));
|
||||||
let margins = get(a, "table:style-name").and_then(|s| self.styles.cell_padding(s));
|
let margins = get(a, "table:style-name").and_then(|s| self.styles.cell_padding(s));
|
||||||
@@ -1391,16 +1405,35 @@ impl Body<'_> {
|
|||||||
// LibreOffice's tables keep their columns' widths, whatever their content (and however
|
// LibreOffice's tables keep their columns' widths, whatever their content (and however
|
||||||
// far past the margins): Word's fixed layout.
|
// far past the margins): Word's fixed layout.
|
||||||
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
|
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
|
||||||
|
// What the shares divide: an aligned table's stated width, however wide, as LibreOffice
|
||||||
|
// keeps it; else its part of the text width.
|
||||||
|
let room = width.filter(|_| align.is_some() && rel.is_none()).or_else(|| width_pct.map(|p| self.sec.text_width() * p / 100.0));
|
||||||
|
let contained = !self.tables.is_empty() || self.containers.len() > 1 || self.sec.columns.count > 1;
|
||||||
let extra =
|
let extra =
|
||||||
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
|
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
|
||||||
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
|
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
|
||||||
|
self.tables.push(TableB {
|
||||||
|
rows: Vec::new(),
|
||||||
|
row: Vec::new(),
|
||||||
|
hcover: 0,
|
||||||
|
widths: Vec::new(),
|
||||||
|
shares: Vec::new(),
|
||||||
|
room,
|
||||||
|
contained,
|
||||||
|
column_backgrounds: Vec::new(),
|
||||||
|
extra,
|
||||||
|
background,
|
||||||
|
row_background: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
|
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
|
||||||
fn start_row(&mut self, a: &Attrs) {
|
fn start_row(&mut self, a: &Attrs) {
|
||||||
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
|
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
|
||||||
|
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
|
||||||
let keep = self.styles.row_keep(get(a, "table:style-name"));
|
let keep = self.styles.row_keep(get(a, "table:style-name"));
|
||||||
if let Some(t) = self.tables.last_mut() {
|
if let Some(t) = self.tables.last_mut() {
|
||||||
|
t.row_background = background;
|
||||||
t.row.clear();
|
t.row.clear();
|
||||||
t.hcover = 0;
|
t.hcover = 0;
|
||||||
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
|
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
|
||||||
|
|||||||
@@ -61,9 +61,12 @@ pub(in crate::odt) fn read_formula(xml: &[u8]) -> Option<(String, bool)> {
|
|||||||
|
|
||||||
/// A frame being read: its width and height, picture bytes, alt text and the equation read from
|
/// A frame being read: its width and height, picture bytes, alt text and the equation read from
|
||||||
/// an embedded formula object.
|
/// an embedded formula object.
|
||||||
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor, its style, and its members so far.
|
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor and page, its style, and its members
|
||||||
|
/// so far.
|
||||||
pub(in crate::odt) struct OpenGroup {
|
pub(in crate::odt) struct OpenGroup {
|
||||||
pub(in crate::odt) anchor: Option<String>,
|
pub(in crate::odt) anchor: Option<String>,
|
||||||
|
/// Its `text:anchor-page-number`.
|
||||||
|
pub(in crate::odt) page: Option<String>,
|
||||||
pub(in crate::odt) style: Option<String>,
|
pub(in crate::odt) style: Option<String>,
|
||||||
pub(in crate::odt) members: Vec<model::Member>,
|
pub(in crate::odt) members: Vec<model::Member>,
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,13 @@ impl Body<'_> {
|
|||||||
self.frame = None;
|
self.frame = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// In line with no stated size, a picture comes at its own: fitted to the text, as
|
||||||
|
// other formats' pictures are. A stated size is the file's.
|
||||||
|
let room = self.sec.text_width();
|
||||||
|
let says = |v: Option<f32>| v.is_some_and(|v| v.is_finite() && v > 0.0);
|
||||||
|
// (Still in line when raised, turned or mirrored.)
|
||||||
|
let in_line = float.as_ref().is_none_or(|f| f.wrap == wordcraft_doc::para::Wrap::Inline);
|
||||||
|
let fit = (!says(w) && !says(h) && in_line && room.is_finite() && room > 0.0).then_some(room);
|
||||||
let inline = match (boxed, eq) {
|
let inline = match (boxed, eq) {
|
||||||
(Some(blocks), _) => {
|
(Some(blocks), _) => {
|
||||||
let mut look = std::mem::take(&mut self.frame_look);
|
let mut look = std::mem::take(&mut self.frame_look);
|
||||||
@@ -146,12 +156,14 @@ impl Body<'_> {
|
|||||||
.and_then(|data| picture(data, w, h, alt.trim()))
|
.and_then(|data| picture(data, w, h, alt.trim()))
|
||||||
.or_else(|| crate::odt::objects::placeholder(&o.mime).and_then(|p| make_img(p, w, h, alt.trim())));
|
.or_else(|| crate::odt::objects::placeholder(&o.mime).and_then(|p| make_img(p, w, h, alt.trim())));
|
||||||
shown.map(|mut img| {
|
shown.map(|mut img| {
|
||||||
|
fit_to(&mut img, fit);
|
||||||
img.float = float.map(Box::new);
|
img.float = float.map(Box::new);
|
||||||
img.object = Some(std::sync::Arc::new(o));
|
img.object = Some(std::sync::Arc::new(o));
|
||||||
Inline::Image(img)
|
Inline::Image(img)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
(None, None) => img.and_then(|data| picture(data, w, h, alt.trim())).map(|mut img| {
|
(None, None) => img.and_then(|data| picture(data, w, h, alt.trim())).map(|mut img| {
|
||||||
|
fit_to(&mut img, fit);
|
||||||
img.float = float.map(Box::new);
|
img.float = float.map(Box::new);
|
||||||
if let Some(clip) = self.frame_clip.take() {
|
if let Some(clip) = self.frame_clip.take() {
|
||||||
img.crop = crop_fractions(clip, &img.data);
|
img.crop = crop_fractions(clip, &img.data);
|
||||||
@@ -338,7 +350,9 @@ impl Body<'_> {
|
|||||||
}
|
}
|
||||||
let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect();
|
let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect();
|
||||||
let graphic = self.styles.shape_graphic(g.style.as_deref());
|
let graphic = self.styles.shape_graphic(g.style.as_deref());
|
||||||
let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
|
let mut float =
|
||||||
|
crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
|
||||||
|
float.page = crate::odt::frames::page_of(g.anchor.as_deref(), g.page.as_deref(), &graphic);
|
||||||
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float));
|
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float));
|
||||||
let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 };
|
let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 };
|
||||||
self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members });
|
self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members });
|
||||||
@@ -358,6 +372,7 @@ impl Body<'_> {
|
|||||||
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
|
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
|
||||||
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
|
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
|
||||||
f.z = crate::odt::frames::z_of(a);
|
f.z = crate::odt::frames::z_of(a);
|
||||||
|
f.page = crate::odt::frames::page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), &g);
|
||||||
f
|
f
|
||||||
} else {
|
} else {
|
||||||
crate::odt::frames::place(a, w, h, &g, self.text_size())
|
crate::odt::frames::place(a, w, h, &g, self.text_size())
|
||||||
@@ -393,14 +408,50 @@ pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
|
|||||||
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
|
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
|
||||||
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
|
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
|
||||||
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
|
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
|
||||||
if wordcraft_metafile::is_metafile(&data) {
|
// What the frame states, if it could be a size (far past any page, kept from overflowing).
|
||||||
|
let stated = |v: Option<f32>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1e6));
|
||||||
|
let (w, h) = (stated(w), stated(h));
|
||||||
|
let mut img = if wordcraft_metafile::is_metafile(&data) {
|
||||||
// An EMF starts with its header record, type 1.
|
// An EMF starts with its header record, type 1.
|
||||||
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||||
// Its own size, when the frame gives none: a placeable WMF's or an EMF's frame, points.
|
// Its own size, when the frame gives none: a placeable WMF's or an EMF's frame, points.
|
||||||
let natural = if w.is_some() && h.is_some() { None } else { metafile_size(&data) };
|
let natural = if w.is_some() && h.is_some() { None } else { metafile_size(&data) };
|
||||||
return Some(model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt));
|
model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt)
|
||||||
|
} else {
|
||||||
|
make_img(data, w, h, alt)?
|
||||||
|
};
|
||||||
|
// A size the frame states is the file's, however large (a picture across a whole page): only
|
||||||
|
// a picture with none is shrunk to a column. Stated on one side, the other follows the
|
||||||
|
// picture's proportions.
|
||||||
|
let (w, h) = match (w, h) {
|
||||||
|
(Some(w), Some(h)) => (w, h),
|
||||||
|
(Some(w), None) => (w, w * height_per_width(&img.data)),
|
||||||
|
(None, Some(h)) => (h / height_per_width(&img.data), h),
|
||||||
|
(None, None) => return Some(img),
|
||||||
|
};
|
||||||
|
// Past what a document holds, it shrinks in proportion.
|
||||||
|
let (w, h) = match w.max(h) {
|
||||||
|
most if most <= 1584.0 => (w, h),
|
||||||
|
_ if w >= h => (1584.0, h * 1584.0 / w),
|
||||||
|
_ => (w * 1584.0 / h, 1584.0),
|
||||||
|
};
|
||||||
|
(img.w, img.h) = (w.max(1.0), h.max(1.0));
|
||||||
|
Some(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `img` shrunk in proportion to `room` points wide, when it's given and the picture is wider.
|
||||||
|
fn fit_to(img: &mut model::Img, room: Option<f32>) {
|
||||||
|
if let Some(room) = room.filter(|room| img.w > *room) {
|
||||||
|
img.h = (img.h * room / img.w).max(1.0);
|
||||||
|
img.w = room.max(1.0);
|
||||||
}
|
}
|
||||||
make_img(data, w, h, alt)
|
}
|
||||||
|
|
||||||
|
/// A picture's height for each point of its width, by its own size, before anything caps it (1
|
||||||
|
/// when it doesn't say).
|
||||||
|
fn height_per_width(data: &[u8]) -> f32 {
|
||||||
|
let own = if wordcraft_metafile::is_metafile(data) { metafile_size(data) } else { model::image_px(data).map(|(w, h)| (w as f32, h as f32)) };
|
||||||
|
own.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map_or(1.0, |(w, h)| h / w)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A metafile's own size, points, from its header: a placeable WMF's bounding box in its units
|
/// A metafile's own size, points, from its header: a placeable WMF's bounding box in its units
|
||||||
|
|||||||
@@ -73,14 +73,67 @@ pub(in crate::odt) struct TableB {
|
|||||||
pub(in crate::odt) hcover: usize,
|
pub(in crate::odt) hcover: usize,
|
||||||
/// Widths in points of the `table:table-column`s so far, 0 where the style gives none.
|
/// Widths in points of the `table:table-column`s so far, 0 where the style gives none.
|
||||||
pub(in crate::odt) widths: Vec<f32>,
|
pub(in crate::odt) widths: Vec<f32>,
|
||||||
|
/// Each column's share of the table (`style:rel-column-width`), 0 where the style gives none.
|
||||||
|
pub(in crate::odt) shares: Vec<f32>,
|
||||||
|
/// The table's width, points: what the shares divide.
|
||||||
|
pub(in crate::odt) room: Option<f32>,
|
||||||
|
/// It is in a cell, a frame or a column narrower than the page, whose width isn't known here.
|
||||||
|
pub(in crate::odt) contained: bool,
|
||||||
/// Its alignment and width, and each row's height so far (see [`crate::model::TableExtra`]).
|
/// Its alignment and width, and each row's height so far (see [`crate::model::TableExtra`]).
|
||||||
pub(in crate::odt) extra: crate::model::TableExtra,
|
pub(in crate::odt) extra: crate::model::TableExtra,
|
||||||
|
/// The background of each column's default cell style (`table:default-cell-style-name`).
|
||||||
|
pub(in crate::odt) column_backgrounds: Vec<Option<wordcraft_doc::props::Rgb>>,
|
||||||
|
/// The table's background, and the row's being read.
|
||||||
|
pub(in crate::odt) background: Option<wordcraft_doc::props::Rgb>,
|
||||||
|
pub(in crate::odt) row_background: Option<wordcraft_doc::props::Rgb>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The narrowest column the document model keeps (a narrower one would lose the table its grid).
|
||||||
|
const MIN_COLUMN: f32 = 6.0;
|
||||||
|
|
||||||
|
/// Each column's width out of `room` points, as LibreOffice shares it out: by its share if it has
|
||||||
|
/// one, else its stated width, which comes off the room first; none narrower than a column can be
|
||||||
|
/// (6 pt). Empty if a column has neither, or no room is left to share.
|
||||||
|
fn widths_of_shares(widths: &[f32], shares: &[f32], room: f32) -> Vec<f32> {
|
||||||
|
let columns = || widths.iter().zip(shares);
|
||||||
|
let usable = |v: f32| v.is_finite() && v > 0.0;
|
||||||
|
let left = room - columns().filter(|(_, s)| **s <= 0.0).map(|(w, _)| *w).sum::<f32>();
|
||||||
|
let total: f32 = shares.iter().filter(|s| **s > 0.0).sum();
|
||||||
|
if widths.len() != shares.len() || !usable(left) || !usable(total) || columns().any(|(w, s)| *w <= 0.0 && *s <= 0.0) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
columns().map(|(w, s)| if *s > 0.0 { (s / total * left).max(MIN_COLUMN) } else { *w }).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TableB {
|
impl TableB {
|
||||||
pub(in crate::odt) fn finish(self) -> FTable {
|
pub(in crate::odt) fn finish(self) -> FTable {
|
||||||
// A column without a width leaves the whole grid to the equal split.
|
// Columns stating their shares (LibreOffice's tables filling the margins, §20.334) divide
|
||||||
let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() };
|
// the table's width by them; a column with neither leaves the whole grid to the equal split.
|
||||||
|
// An aligned table's shares go before the widths its columns state too, as LibreOffice
|
||||||
|
// reads them. (A table filling the margins is fitted to them whatever its widths say, and
|
||||||
|
// files give it both in proportion; in something narrower than the page, whose width isn't
|
||||||
|
// known here, stated widths are the best guess.)
|
||||||
|
let all_widths = self.widths.iter().all(|w| *w > 0.0);
|
||||||
|
let all_shares = !self.shares.is_empty() && self.shares.iter().all(|s| *s > 0.0);
|
||||||
|
let aligned = self.extra.align.is_some() && !self.contained;
|
||||||
|
let by_shares = self
|
||||||
|
.room
|
||||||
|
.filter(|_| all_shares && (aligned || !all_widths))
|
||||||
|
.map(|room| widths_of_shares(&self.widths, &self.shares, room))
|
||||||
|
.filter(|w| !w.is_empty());
|
||||||
|
let widths = if let Some(w) = by_shares {
|
||||||
|
w
|
||||||
|
} else if all_widths {
|
||||||
|
self.widths
|
||||||
|
} else {
|
||||||
|
// With nothing to divide, or in something narrower than the page with widths stated
|
||||||
|
// too, the shares can't be worked out here: the equal split, as before.
|
||||||
|
let mixed = self.shares.iter().any(|s| *s <= 0.0);
|
||||||
|
match self.room.filter(|_| !(self.contained && mixed)) {
|
||||||
|
Some(room) => widths_of_shares(&self.widths, &self.shares, room),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut rows = self.rows;
|
let mut rows = self.rows;
|
||||||
share_edges(&mut rows);
|
share_edges(&mut rows);
|
||||||
// A table whose cells all say "no line" draws none.
|
// A table whose cells all say "no line" draws none.
|
||||||
|
|||||||
@@ -417,12 +417,26 @@ pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float
|
|||||||
f.rot = rot;
|
f.rot = rot;
|
||||||
}
|
}
|
||||||
f.z = z_of(a);
|
f.z = z_of(a);
|
||||||
|
f.page = page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), g);
|
||||||
if f.wrap == Wrap::Inline {
|
if f.wrap == Wrap::Inline {
|
||||||
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
|
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
|
||||||
}
|
}
|
||||||
f
|
f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The page a drawing anchored as `anchor` (or as its style says) is on: `number`, its
|
||||||
|
/// `text:anchor-page-number` (§19.872). 0 when it states none, or isn't anchored to a page, where
|
||||||
|
/// LibreOffice ignores the number.
|
||||||
|
pub(super) fn page_of(anchor: Option<&str>, number: Option<&str>, g: &Graphic) -> u32 {
|
||||||
|
if anchor.or(g.anchor.as_deref()).map(str::trim) != Some("page") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
number.and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0).min(MAX_ANCHOR_PAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest anchor page kept.
|
||||||
|
const MAX_ANCHOR_PAGE: u32 = 1_000_000;
|
||||||
|
|
||||||
/// A drawing's place in the stacking order (§19.231 `draw:z-index`, from 0 at the bottom) as
|
/// A drawing's place in the stacking order (§19.231 `draw:z-index`, from 0 at the bottom) as
|
||||||
/// WordCraft's [`Float::z`] (higher on top, 0 for unknown): one more.
|
/// WordCraft's [`Float::z`] (higher on top, 0 for unknown): one more.
|
||||||
pub(super) fn z_of(a: &Attrs) -> u32 {
|
pub(super) fn z_of(a: &Attrs) -> u32 {
|
||||||
@@ -564,7 +578,10 @@ pub(super) fn turned(f: &Float) -> bool {
|
|||||||
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its
|
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its
|
||||||
/// `draw:transform` when it is turned (`w` × `h` its size).
|
/// `draw:transform` when it is turned (`w` × `h` its size).
|
||||||
pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) {
|
pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) {
|
||||||
let anchor = if f.h_rel == Anchor::Page && f.v_rel == Anchor::Page {
|
// A page number holds until it is placed by its paragraph: moved in WordCraft.
|
||||||
|
let by_paragraph =
|
||||||
|
matches!(f.h_rel, Anchor::Column | Anchor::Character) || matches!(f.v_rel, Anchor::Paragraph | Anchor::Line | Anchor::Character);
|
||||||
|
let anchor = if (f.page > 0 && !by_paragraph) || (f.h_rel == Anchor::Page && f.v_rel == Anchor::Page) {
|
||||||
"page"
|
"page"
|
||||||
} else if f.v_rel == Anchor::Line {
|
} else if f.v_rel == Anchor::Line {
|
||||||
"char"
|
"char"
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ fn add_custom_styles(doc: &mut Document, named: &[super::styles::NamedStyle], od
|
|||||||
}),
|
}),
|
||||||
contextual_spacing: look.contextual,
|
contextual_spacing: look.contextual,
|
||||||
bidi: look.rtl.filter(|r| *r),
|
bidi: look.rtl.filter(|r| *r),
|
||||||
|
// The document's nine levels; ODF's tenth is the last of them.
|
||||||
|
outline_level: def.outline.map(|o| o.clamp(1, 9) - 1),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let name = super::writer::generating_style(name).map_or_else(|| def.name.clone(), |(_, n)| n);
|
let name = super::writer::generating_style(name).map_or_else(|| def.name.clone(), |(_, n)| n);
|
||||||
@@ -327,7 +329,7 @@ fn turned_off(mut chr: wordcraft_doc::props::CharProps, off: [bool; 4]) -> wordc
|
|||||||
chr.underline = Some(wordcraft_doc::props::Underline::None);
|
chr.underline = Some(wordcraft_doc::props::Underline::None);
|
||||||
}
|
}
|
||||||
if strike {
|
if strike {
|
||||||
chr.strike = Some(false);
|
(chr.strike, chr.double_strike) = (Some(false), Some(false));
|
||||||
}
|
}
|
||||||
chr
|
chr
|
||||||
}
|
}
|
||||||
@@ -362,7 +364,7 @@ fn apply_look(doc: &mut Document, kind: model::Kind, look: &super::styles::ParaL
|
|||||||
fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
|
fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
|
||||||
use wordcraft_doc::numbering::{AbstractNum, ListKind, Num, levels_for};
|
use wordcraft_doc::numbering::{AbstractNum, ListKind, Num, levels_for};
|
||||||
use wordcraft_doc::props::NumRef;
|
use wordcraft_doc::props::NumRef;
|
||||||
let numbered: Vec<bool> = (0..6).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
|
let numbered: Vec<bool> = (0..9).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
|
||||||
if !numbered.contains(&true) {
|
if !numbered.contains(&true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -395,11 +397,155 @@ fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
|
|||||||
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels: out, ..Default::default() });
|
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels: out, ..Default::default() });
|
||||||
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||||
numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||||
|
let styles = Arc::make_mut(&mut doc.styles);
|
||||||
for (i, on) in numbered.iter().enumerate() {
|
for (i, on) in numbered.iter().enumerate() {
|
||||||
if *on && let Some(st) = Arc::make_mut(&mut doc.styles).get_mut(&format!("Heading{}", i + 1)) {
|
if *on && let Some(st) = styles.get_mut(&format!("Heading{}", i + 1)) {
|
||||||
st.para.numbering = Some(NumRef { num: nid, level: i as u8 });
|
st.para.numbering = Some(NumRef { num: nid, level: i as u8 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// LibreOffice numbers a custom style at an outline level along with that level's headings.
|
||||||
|
for st in &mut styles.styles {
|
||||||
|
if let Some(l) = st.para.outline_level.filter(|l| numbered.get(usize::from(*l)) == Some(&true)) {
|
||||||
|
st.para.numbering = Some(NumRef { num: nid, level: l });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A style at a level the outline doesn't number doesn't take its parent's number from it.
|
||||||
|
let of_outline = |st: &wordcraft_doc::styles::Style| {
|
||||||
|
let at = wordcraft_doc::props::ParaProps { style: Some(st.id.clone()), ..Default::default() };
|
||||||
|
st.para.numbering.is_none()
|
||||||
|
&& st.para.outline_level.is_some_and(|l| numbered.get(usize::from(l)) != Some(&true))
|
||||||
|
&& styles.resolve_para(&at).numbering.is_some_and(|n| n.num == nid)
|
||||||
|
};
|
||||||
|
let unnumbered: Vec<String> = styles.styles.iter().filter(|st| of_outline(st)).map(|st| st.id.clone()).collect();
|
||||||
|
for id in unnumbered {
|
||||||
|
if let Some(st) = styles.get_mut(&id) {
|
||||||
|
// (`num: 0` switches the number off.)
|
||||||
|
st.para.numbering = Some(NumRef { num: 0, level: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A heading's own outline level goes before its style's: LibreOffice numbers it by that level.
|
||||||
|
// A heading in a list is that list's.
|
||||||
|
let styles = doc.styles.clone();
|
||||||
|
each_body_para(&mut doc.body, 0, &mut |p| {
|
||||||
|
let Some(l) = p.props.outline_level.filter(|_| p.props.numbering.is_none()) else { return };
|
||||||
|
let style = styles.resolve_para(&wordcraft_doc::props::ParaProps { style: p.props.style.clone(), ..Default::default() });
|
||||||
|
if style.outline_level.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let own = (numbered.get(usize::from(l)) == Some(&true)).then_some(NumRef { num: nid, level: l });
|
||||||
|
if own != style.numbering {
|
||||||
|
// (`num: 0` switches the style's number off.)
|
||||||
|
p.props.numbering = Some(own.unwrap_or(NumRef { num: 0, level: 0 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each paragraph of `blocks`, those in tables too (to 16 levels deep).
|
||||||
|
fn each_body_para(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, f: &mut dyn FnMut(&mut wordcraft_doc::Paragraph)) {
|
||||||
|
for b in blocks {
|
||||||
|
match Arc::make_mut(b) {
|
||||||
|
wordcraft_doc::Block::Para(p) => f(p),
|
||||||
|
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||||
|
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||||
|
each_body_para(&mut cell.blocks, depth + 1, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordcraft_doc::Block::Table(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a run's complex size comes from: its paragraph's style, its character style, and the size
|
||||||
|
/// and complex size it states (as bits).
|
||||||
|
type SizeSource<'a> = (Option<&'a str>, Option<&'a str>, Option<u32>, Option<u32>);
|
||||||
|
|
||||||
|
/// A complex-script size the same as the text's (what WordCraft writes for every script) is no size
|
||||||
|
/// of its own: a later change of size takes it along. The default's and the styles' go together,
|
||||||
|
/// unless a run's complex size would change: a size stated alone, or another complex size between
|
||||||
|
/// them, shows what's above it, so they stay as the file says. A run's goes where its stays the same.
|
||||||
|
fn one_size_for_all_scripts(doc: &mut Document) {
|
||||||
|
let mut sources = std::collections::BTreeSet::new();
|
||||||
|
for blocks in std::iter::once(&doc.body).chain(doc.parts.values().map(|part| &part.blocks)) {
|
||||||
|
size_sources(blocks, 0, &mut sources);
|
||||||
|
}
|
||||||
|
let sizes = |styles: &wordcraft_doc::styles::StyleSheet| -> Vec<f32> {
|
||||||
|
let of = |(para, chr, size, size_cs): &SizeSource| {
|
||||||
|
let run = wordcraft_doc::props::CharProps {
|
||||||
|
style: chr.map(str::to_string),
|
||||||
|
size: size.map(f32::from_bits),
|
||||||
|
size_cs: size_cs.map(f32::from_bits),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
styles.resolve_char(*para, &run).size_cs
|
||||||
|
};
|
||||||
|
sources.iter().map(of).collect()
|
||||||
|
};
|
||||||
|
let as_read = Arc::clone(&doc.styles);
|
||||||
|
let was = sizes(&as_read);
|
||||||
|
let styles = Arc::make_mut(&mut doc.styles);
|
||||||
|
let one_size = |c: &mut wordcraft_doc::props::CharProps| {
|
||||||
|
if c.size.is_some() && c.size_cs == c.size {
|
||||||
|
c.size_cs = None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
one_size(&mut styles.default_chr);
|
||||||
|
styles.styles.iter_mut().for_each(|st| one_size(&mut st.chr));
|
||||||
|
if sizes(&doc.styles) != was {
|
||||||
|
doc.styles = as_read;
|
||||||
|
}
|
||||||
|
let styles = Arc::clone(&doc.styles);
|
||||||
|
let stories = std::iter::once(&mut doc.body).chain(doc.parts.values_mut().map(|part| &mut part.blocks));
|
||||||
|
for blocks in stories {
|
||||||
|
runs_one_size(blocks, 0, &styles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the runs in `blocks` (and their tables, to 16 levels deep) take their complex sizes from.
|
||||||
|
fn size_sources<'a>(blocks: &'a [Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<SizeSource<'a>>) {
|
||||||
|
for b in blocks {
|
||||||
|
match &**b {
|
||||||
|
wordcraft_doc::Block::Para(p) => {
|
||||||
|
for r in &p.runs {
|
||||||
|
out.insert((
|
||||||
|
p.props.style.as_deref(),
|
||||||
|
r.props.style.as_deref(),
|
||||||
|
r.props.size.map(f32::to_bits),
|
||||||
|
r.props.size_cs.map(f32::to_bits),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||||
|
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||||
|
size_sources(&cell.blocks, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordcraft_doc::Block::Table(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`one_size_for_all_scripts`] for the runs in `blocks` (and their tables, to 16 levels deep).
|
||||||
|
fn runs_one_size(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, styles: &wordcraft_doc::styles::StyleSheet) {
|
||||||
|
for b in blocks {
|
||||||
|
match Arc::make_mut(b) {
|
||||||
|
wordcraft_doc::Block::Para(p) => {
|
||||||
|
let style = p.props.style.clone();
|
||||||
|
for r in &mut p.runs {
|
||||||
|
let Some(said) = r.props.size_cs.filter(|s| Some(*s) == r.props.size) else { continue };
|
||||||
|
r.props.size_cs = None;
|
||||||
|
if styles.resolve_char(style.as_deref(), &r.props).size_cs != said {
|
||||||
|
r.props.size_cs = Some(said);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||||
|
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||||
|
runs_one_size(&mut cell.blocks, depth + 1, styles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordcraft_doc::Block::Table(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The boolean configuration item `name` of `settings.xml` (ODF 1.3 §3.10), when it has one.
|
/// The boolean configuration item `name` of `settings.xml` (ODF 1.3 §3.10), when it has one.
|
||||||
@@ -655,6 +801,8 @@ struct Parsed {
|
|||||||
widows: Option<bool>,
|
widows: Option<bool>,
|
||||||
/// The document's default Asian and complex-script fonts and languages.
|
/// The document's default Asian and complex-script fonts and languages.
|
||||||
scripts: wordcraft_doc::props::CharProps,
|
scripts: wordcraft_doc::props::CharProps,
|
||||||
|
/// The document's default language, if it states one.
|
||||||
|
default_lang: Option<String>,
|
||||||
/// The default tab interval, points.
|
/// The default tab interval, points.
|
||||||
default_tab: Option<f32>,
|
default_tab: Option<f32>,
|
||||||
/// Tab stops are measured from the indent (LibreOffice's `TabsRelativeToIndent`).
|
/// Tab stops are measured from the indent (LibreOffice's `TabsRelativeToIndent`).
|
||||||
@@ -850,6 +998,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
|
|||||||
kern: styles.default_kern,
|
kern: styles.default_kern,
|
||||||
widows: styles.stated_widows(),
|
widows: styles.stated_widows(),
|
||||||
scripts: styles.default_scripts.clone(),
|
scripts: styles.default_scripts.clone(),
|
||||||
|
default_lang: styles.document_lang(),
|
||||||
default_tab: styles.default_tab,
|
default_tab: styles.default_tab,
|
||||||
tabs_from_indent: styles.tabs_from_indent,
|
tabs_from_indent: styles.tabs_from_indent,
|
||||||
page_doc: pages.doc.clone(),
|
page_doc: pages.doc.clone(),
|
||||||
@@ -895,6 +1044,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
|||||||
kern,
|
kern,
|
||||||
widows,
|
widows,
|
||||||
scripts,
|
scripts,
|
||||||
|
default_lang,
|
||||||
default_tab,
|
default_tab,
|
||||||
tabs_from_indent,
|
tabs_from_indent,
|
||||||
page_doc,
|
page_doc,
|
||||||
@@ -932,6 +1082,9 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
|||||||
}
|
}
|
||||||
Arc::make_mut(&mut doc.styles).default_chr.kern = kern.then_some(1.0);
|
Arc::make_mut(&mut doc.styles).default_chr.kern = kern.then_some(1.0);
|
||||||
Arc::make_mut(&mut doc.styles).default_chr.overlay(&scripts);
|
Arc::make_mut(&mut doc.styles).default_chr.overlay(&scripts);
|
||||||
|
// The file's own, or none when it states none (each application then uses its own default):
|
||||||
|
// not the English of WordCraft's new documents.
|
||||||
|
Arc::make_mut(&mut doc.styles).default_chr.lang = default_lang;
|
||||||
if let Some(t) = default_tab {
|
if let Some(t) = default_tab {
|
||||||
doc.settings.default_tab = t;
|
doc.settings.default_tab = t;
|
||||||
}
|
}
|
||||||
@@ -970,6 +1123,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
|
|||||||
}
|
}
|
||||||
doc.last_section = last;
|
doc.last_section = last;
|
||||||
page_parts::add(&mut doc, parts);
|
page_parts::add(&mut doc, parts);
|
||||||
|
one_size_for_all_scripts(&mut doc);
|
||||||
if let Some(t) = track {
|
if let Some(t) = track {
|
||||||
doc.settings.track_changes = t;
|
doc.settings.track_changes = t;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ fn columns_xml(c: &Columns) -> String {
|
|||||||
/// Preformatted Text), each with the look of the document's own style: font, size, bold,
|
/// Preformatted Text), each with the look of the document's own style: font, size, bold,
|
||||||
/// italic, colour and the rest of its text formatting, spacing, indents, alignment and keep with
|
/// italic, colour and the rest of its text formatting, spacing, indents, alignment and keep with
|
||||||
/// next, written out in full so other programs show them as WordCraft does.
|
/// next, written out in full so other programs show them as WordCraft does.
|
||||||
fn kind_styles(doc: &Document) -> String {
|
fn kind_styles(doc: &Document, on_outline: &std::collections::BTreeSet<String>) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
let mut kinds: Vec<(String, String, String, Option<u8>, &str)> =
|
let mut kinds: Vec<(String, String, String, Option<u8>, &str)> =
|
||||||
(1..=6).map(|n| (format!("Heading_20_{n}"), format!("Heading {n}"), format!("Heading{n}"), Some(n), "text")).collect();
|
(1..=6).map(|n| (format!("Heading_20_{n}"), format!("Heading {n}"), format!("Heading{n}"), Some(n), "text")).collect();
|
||||||
@@ -63,14 +63,7 @@ fn kind_styles(doc: &Document) -> String {
|
|||||||
if let Some(c) = pp.contextual_spacing {
|
if let Some(c) = pp.contextual_spacing {
|
||||||
para.push_str(&format!(" style:contextual-spacing=\"{c}\""));
|
para.push_str(&format!(" style:contextual-spacing=\"{c}\""));
|
||||||
}
|
}
|
||||||
// A heading level the outline doesn't number names no list style: LibreOffice would
|
let outline = outline.map(|n| outline_attrs(doc, n, &pp, on_outline.contains(&id))).unwrap_or_default();
|
||||||
// otherwise give it the outline's (unnumbered) level, and its indent.
|
|
||||||
let numbered = pp
|
|
||||||
.numbering
|
|
||||||
.and_then(|r| doc.numbering.level(r.num, r.level))
|
|
||||||
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
|
|
||||||
let detach = if outline.is_some() && !numbered { " style:list-style-name=\"\"" } else { "" };
|
|
||||||
let outline = outline.map(|n| format!(" style:default-outline-level=\"{n}\"{detach}")).unwrap_or_default();
|
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"<style:style style:name=\"{name}\" style:display-name=\"{display}\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:next-style-name=\"Standard\"{outline} style:class=\"{class}\"><style:paragraph-properties{para}/><style:text-properties{}/></style:style>",
|
"<style:style style:name=\"{name}\" style:display-name=\"{display}\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:next-style-name=\"Standard\"{outline} style:class=\"{class}\"><style:paragraph-properties{para}/><style:text-properties{}/></style:style>",
|
||||||
props_text(&cp)
|
props_text(&cp)
|
||||||
@@ -79,6 +72,42 @@ fn kind_styles(doc: &Document) -> String {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The styles of the headings in `blocks` (and their tables, to 16 levels deep) with a number by
|
||||||
|
/// their own outline level.
|
||||||
|
fn styles_numbered_by_own_level(blocks: &[std::sync::Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<String>) {
|
||||||
|
for b in blocks {
|
||||||
|
match &**b {
|
||||||
|
wordcraft_doc::Block::Para(p) => {
|
||||||
|
if p.props.outline_level.is_some()
|
||||||
|
&& p.props.numbering.is_some_and(|n| n.num != 0)
|
||||||
|
&& let Some(s) = &p.props.style
|
||||||
|
{
|
||||||
|
out.insert(s.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordcraft_doc::Block::Table(t) if depth < 16 => {
|
||||||
|
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||||
|
styles_numbered_by_own_level(&cell.blocks, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordcraft_doc::Block::Table(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A paragraph style's outline level `n` (`style:default-outline-level`), its properties being `pp`.
|
||||||
|
fn outline_attrs(doc: &Document, n: u8, pp: &wordcraft_doc::props::ParaProps, on_outline: bool) -> String {
|
||||||
|
// A level the outline doesn't number names no list style: LibreOffice would otherwise give
|
||||||
|
// it the outline's (unnumbered) level, and its indent. Unless a heading in the style has a
|
||||||
|
// number by its own level (`on_outline`), which that would take from it.
|
||||||
|
let numbered = pp
|
||||||
|
.numbering
|
||||||
|
.and_then(|r| doc.numbering.level(r.num, r.level))
|
||||||
|
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
|
||||||
|
let detach = if numbered || on_outline { "" } else { " style:list-style-name=\"\"" };
|
||||||
|
format!(" style:default-outline-level=\"{n}\"{detach}")
|
||||||
|
}
|
||||||
|
|
||||||
/// The ODF parent of document style `id`: Standard for Normal, the kinds' styles for theirs, a
|
/// The ODF parent of document style `id`: Standard for Normal, the kinds' styles for theirs, a
|
||||||
/// custom style by its id.
|
/// custom style by its id.
|
||||||
fn odf_parent(doc: &Document, id: Option<&str>) -> String {
|
fn odf_parent(doc: &Document, id: Option<&str>) -> String {
|
||||||
@@ -98,7 +127,7 @@ fn odf_parent(doc: &Document, id: Option<&str>) -> String {
|
|||||||
/// The custom paragraph styles in `named` and the custom styles they are based on, as common
|
/// The custom paragraph styles in `named` and the custom styles they are based on, as common
|
||||||
/// styles (ODF 1.3 §16.2): named by their document id, shown by their name, each with its own
|
/// styles (ODF 1.3 §16.2): named by their document id, shown by their name, each with its own
|
||||||
/// paragraph and text formatting over its parent's.
|
/// paragraph and text formatting over its parent's.
|
||||||
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
|
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>, on_outline: &std::collections::BTreeSet<String>) -> String {
|
||||||
let mut todo: Vec<String> = named.iter().cloned().collect();
|
let mut todo: Vec<String> = named.iter().cloned().collect();
|
||||||
let mut done = std::collections::BTreeSet::new();
|
let mut done = std::collections::BTreeSet::new();
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
@@ -146,16 +175,29 @@ fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) ->
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let parent = odf_parent(doc, st.based_on.as_deref());
|
let parent = odf_parent(doc, st.based_on.as_deref());
|
||||||
|
let outline = pp.outline_level.filter(|l| *l < 9).map(|l| outline_attrs(doc, l + 1, pp, on_outline.contains(&id))).unwrap_or_default();
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
|
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{outline}><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
|
||||||
super::writer::odf_name(&id),
|
super::writer::odf_name(&id),
|
||||||
x(&super::writer::generated_style(&id).map_or_else(|| st.name.clone(), |(_, d)| d)),
|
x(&super::writer::generated_style(&id).map_or_else(|| st.name.clone(), |(_, d)| d)),
|
||||||
props_text(&st.chr)
|
props_text(&with_complex_size(doc, &id, &st.chr))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paragraph style `id`'s own text formatting `chr`, with the complex-script size it takes when it
|
||||||
|
/// states only a size: a parent's complex size where one sets it (the model takes it from there,
|
||||||
|
/// and the writer would otherwise give it this style's size), else its own size.
|
||||||
|
fn with_complex_size(doc: &Document, id: &str, chr: &wordcraft_doc::props::CharProps) -> wordcraft_doc::props::CharProps {
|
||||||
|
let mut chr = chr.clone();
|
||||||
|
if chr.size.is_some() && chr.size_cs.is_none() {
|
||||||
|
let cs = doc.styles.resolve_char(Some(id), &wordcraft_doc::props::CharProps::default()).size_cs;
|
||||||
|
chr.size_cs = (chr.size != Some(cs)).then_some(cs);
|
||||||
|
}
|
||||||
|
chr
|
||||||
|
}
|
||||||
|
|
||||||
/// The custom character styles in `named` and those they are based on, as common text styles.
|
/// The custom character styles in `named` and those they are based on, as common text styles.
|
||||||
fn char_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
|
fn char_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
|
||||||
let mut todo: Vec<String> = named.iter().cloned().collect();
|
let mut todo: Vec<String> = named.iter().cloned().collect();
|
||||||
@@ -346,25 +388,31 @@ fn styles_xml(
|
|||||||
let tab = doc.settings.default_tab.clamp(1.0, 1584.0);
|
let tab = doc.settings.default_tab.clamp(1.0, 1584.0);
|
||||||
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
|
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:letter-kerning=\"{kern}\"{color}{scripts}/></style:default-style>",
|
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:font-size-asian=\"{size}\"{size_cs} style:letter-kerning=\"{kern}\"{color}{lang}{scripts}/></style:default-style>",
|
||||||
spacing_props(defaults),
|
spacing_props(defaults),
|
||||||
x(&body_font),
|
x(&body_font),
|
||||||
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
|
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
|
||||||
// when unsaid, and readers differ: docs/odf-interpretation.md).
|
// when unsaid, and readers differ: docs/odf-interpretation.md).
|
||||||
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
|
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
|
||||||
size = pt(size),
|
size = pt(size),
|
||||||
|
size_cs = if doc.styles.default_chr.size_cs.is_none() { format!(" style:font-size-complex=\"{}\"", pt(size)) } else { String::new() },
|
||||||
// The document's text colour (a colour, not automatic).
|
// The document's text colour (a colour, not automatic).
|
||||||
color = match doc.styles.default_chr.color {
|
color = match doc.styles.default_chr.color {
|
||||||
Some(wordcraft_doc::props::TextColor::Rgb(c)) => format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase()),
|
Some(wordcraft_doc::props::TextColor::Rgb(c)) => format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase()),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
},
|
},
|
||||||
tab = pt(tab),
|
tab = pt(tab),
|
||||||
|
// The document's language, without which each reader uses its own default: Normal's, as
|
||||||
|
// WordCraft has it (a Word file's Normal may state another than its defaults).
|
||||||
|
lang = doc.styles.resolve_char(None, &Default::default()).lang.as_deref().map(|l| super::writer::lang_attrs(l, "")).unwrap_or_default(),
|
||||||
scripts = super::writer::script_attrs(&doc.styles.default_chr)
|
scripts = super::writer::script_attrs(&doc.styles.default_chr)
|
||||||
));
|
));
|
||||||
s.push_str("<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>");
|
s.push_str("<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>");
|
||||||
s.push_str(&kind_styles(doc));
|
let mut on_outline = std::collections::BTreeSet::new();
|
||||||
|
styles_numbered_by_own_level(&doc.body, 0, &mut on_outline);
|
||||||
|
s.push_str(&kind_styles(doc, &on_outline));
|
||||||
s.push_str(&outline_style(doc));
|
s.push_str(&outline_style(doc));
|
||||||
s.push_str(&custom_styles(doc, named));
|
s.push_str(&custom_styles(doc, named, &on_outline));
|
||||||
// The note text styles, as WordCraft's Footnote Text and Endnote Text: 10 pt, no spacing.
|
// The note text styles, as WordCraft's Footnote Text and Endnote Text: 10 pt, no spacing.
|
||||||
for (name, class) in [("Footnote", "extra"), ("Endnote", "extra")] {
|
for (name, class) in [("Footnote", "extra"), ("Endnote", "extra")] {
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ impl FmtDelta {
|
|||||||
($($k:ident),*) => { $( if let Some(v) = self.$k.clone() { f.$k = v; } )* };
|
($($k:ident),*) => { $( if let Some(v) = self.$k.clone() { f.$k = v; } )* };
|
||||||
}
|
}
|
||||||
set!(bold, italic, underline, strike, sup, sub, code);
|
set!(bold, italic, underline, strike, sup, sub, code);
|
||||||
|
// What this turns off stays said on the text, or the style of its paragraph or span would
|
||||||
|
// show through; turned back on, it no longer is.
|
||||||
|
if self.bold.is_some() || self.italic.is_some() || self.underline.is_some() || self.strike.is_some() {
|
||||||
|
use wordcraft_doc::props::Underline;
|
||||||
|
let mut x = f.extra.take().map(|b| *b).unwrap_or_default();
|
||||||
|
if self.strike == Some(false) {
|
||||||
|
x.double_strike = None;
|
||||||
|
}
|
||||||
|
for (state, said) in [(self.bold, &mut x.bold), (self.italic, &mut x.italic), (self.strike, &mut x.strike)] {
|
||||||
|
if let Some(on) = state {
|
||||||
|
*said = (!on).then_some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.underline {
|
||||||
|
Some(false) => x.underline = Some(Underline::None),
|
||||||
|
Some(true) if x.underline == Some(Underline::None) => x.underline = None,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
f.extra = (x != wordcraft_doc::props::CharProps::default()).then(|| Box::new(x));
|
||||||
|
}
|
||||||
if self.color.is_some() {
|
if self.color.is_some() {
|
||||||
f.color = self.color;
|
f.color = self.color;
|
||||||
}
|
}
|
||||||
@@ -61,10 +81,12 @@ impl FmtDelta {
|
|||||||
let size = f.size.unwrap_or(12.0);
|
let size = f.size.unwrap_or(12.0);
|
||||||
x.position = Some((r / 100.0 * size).clamp(-1584.0, 1584.0)).filter(|p| *p != 0.0);
|
x.position = Some((r / 100.0 * size).clamp(-1584.0, 1584.0)).filter(|p| *p != 0.0);
|
||||||
}
|
}
|
||||||
// An explicit "off" only cancels what a parent style set.
|
// An explicit "off" only cancels what a parent style set (capitals are kept: a
|
||||||
for b in
|
// paragraph's or span's style may set them too).
|
||||||
[&mut x.double_strike, &mut x.caps, &mut x.small_caps, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave]
|
if self.strike == Some(false) {
|
||||||
{
|
x.double_strike = None;
|
||||||
|
}
|
||||||
|
for b in [&mut x.double_strike, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave] {
|
||||||
if *b == Some(false) {
|
if *b == Some(false) {
|
||||||
*b = None;
|
*b = None;
|
||||||
}
|
}
|
||||||
@@ -123,14 +145,10 @@ fn script_properties(a: &Attrs, x: &mut wordcraft_doc::props::CharProps, faces:
|
|||||||
if let Some(s) = get(a, "style:font-style-complex") {
|
if let Some(s) = get(a, "style:font-style-complex") {
|
||||||
x.italic_cs = Some(matches!(s.trim(), "italic" | "oblique"));
|
x.italic_cs = Some(matches!(s.trim(), "italic" | "oblique"));
|
||||||
}
|
}
|
||||||
fn tag(v: Option<&str>) -> Option<&str> {
|
if let Some(l) = language_tag(a, "style:language-asian", "style:script-asian", "style:country-asian") {
|
||||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none" && *v != "zxx")
|
|
||||||
}
|
|
||||||
let lang = |l: &str, c: &str| tag(get(a, l)).map(|l| tag(get(a, c)).map_or_else(|| l.to_string(), |c| format!("{l}-{c}")));
|
|
||||||
if let Some(l) = lang("style:language-asian", "style:country-asian") {
|
|
||||||
x.lang_ea = Some(l);
|
x.lang_ea = Some(l);
|
||||||
}
|
}
|
||||||
if let Some(l) = lang("style:language-complex", "style:country-complex") {
|
if let Some(l) = language_tag(a, "style:language-complex", "style:script-complex", "style:country-complex") {
|
||||||
x.lang_bidi = Some(l);
|
x.lang_bidi = Some(l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,15 +221,26 @@ fn extra_text_properties(a: &Attrs, d: &mut FmtDelta) {
|
|||||||
if let Some(v) = get(a, "style:font-relief") {
|
if let Some(v) = get(a, "style:font-relief") {
|
||||||
(x.emboss, x.engrave) = (Some(v.trim() == "embossed"), Some(v.trim() == "engraved"));
|
(x.emboss, x.engrave) = (Some(v.trim() == "embossed"), Some(v.trim() == "engraved"));
|
||||||
}
|
}
|
||||||
|
if let Some(l) = language(a) {
|
||||||
|
x.lang = Some(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language `fo:language`, `fo:script` and `fo:country` state: see [`language_tag`].
|
||||||
|
fn language(a: &Attrs) -> Option<String> {
|
||||||
|
language_tag(a, "fo:language", "fo:script", "fo:country")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language the attributes `language`, `script` and `country` state, as a tag (`de-DE`,
|
||||||
|
/// `sr-Latn-RS`); `None` when they state none, or no language at all (`none`, `zxx`). A script of
|
||||||
|
/// any other shape than a script's is left out: written back, it would read as a country.
|
||||||
|
fn language_tag(a: &Attrs, language: &str, script: &str, country: &str) -> Option<String> {
|
||||||
fn tag(v: Option<&str>) -> Option<&str> {
|
fn tag(v: Option<&str>) -> Option<&str> {
|
||||||
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()))
|
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none")
|
||||||
}
|
|
||||||
if let Some(l) = tag(get(a, "fo:language")).filter(|l| *l != "none" && *l != "zxx") {
|
|
||||||
x.lang = Some(match tag(get(a, "fo:country")).filter(|c| *c != "none") {
|
|
||||||
Some(c) => format!("{l}-{c}"),
|
|
||||||
None => l.to_string(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
let language = tag(get(a, language)).filter(|l| *l != "zxx")?;
|
||||||
|
let script = tag(get(a, script)).filter(|s| super::writer::is_script(s));
|
||||||
|
Some([Some(language), script, tag(get(a, country))].into_iter().flatten().collect::<Vec<_>>().join("-"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -229,7 +258,7 @@ struct StyleInfo {
|
|||||||
outline: Option<u8>,
|
outline: Option<u8>,
|
||||||
border_bottom: bool,
|
border_bottom: bool,
|
||||||
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
|
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
|
||||||
cell_background: Option<Rgb>,
|
cell_background: Option<Option<Rgb>>,
|
||||||
/// A table-cell style's background picture (§17.3 `style:background-image`): its package
|
/// A table-cell style's background picture (§17.3 `style:background-image`): its package
|
||||||
/// path, `style:repeat` and `style:position`.
|
/// path, `style:repeat` and `style:position`.
|
||||||
cell_picture: Option<(String, String, String)>,
|
cell_picture: Option<(String, String, String)>,
|
||||||
@@ -242,6 +271,8 @@ struct StyleInfo {
|
|||||||
cell_padding: [Option<f32>; 4],
|
cell_padding: [Option<f32>; 4],
|
||||||
/// `style:column-width` of a table-column style's `style:table-column-properties`, in points.
|
/// `style:column-width` of a table-column style's `style:table-column-properties`, in points.
|
||||||
column_width: Option<f32>,
|
column_width: Option<f32>,
|
||||||
|
/// `style:rel-column-width` of the same: its share of the table (`1*`).
|
||||||
|
column_share: Option<f32>,
|
||||||
/// A table style's `style:table-properties`: `table:align`, and its width (`style:width`,
|
/// A table style's `style:table-properties`: `table:align`, and its width (`style:width`,
|
||||||
/// points, or `style:rel-width`, percent of the text width).
|
/// points, or `style:rel-width`, percent of the text width).
|
||||||
table_align: Option<Align>,
|
table_align: Option<Align>,
|
||||||
@@ -252,6 +283,9 @@ struct StyleInfo {
|
|||||||
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
|
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
|
||||||
/// or least height (`style:min-row-height`), points.
|
/// or least height (`style:min-row-height`), points.
|
||||||
row_height: Option<(f32, bool)>,
|
row_height: Option<(f32, bool)>,
|
||||||
|
/// `fo:background-color` of a table or table-row style (§20.175): what shows behind cells
|
||||||
|
/// with none of their own. `Some(None)`: `transparent`, which clears a parent style's.
|
||||||
|
table_background: Option<Option<Rgb>>,
|
||||||
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
|
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
|
||||||
row_keep: Option<bool>,
|
row_keep: Option<bool>,
|
||||||
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
|
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
|
||||||
@@ -455,6 +489,8 @@ pub(super) struct NamedStyle {
|
|||||||
/// What it turns off, which a style it is based on may turn on: bold, italic, underline,
|
/// What it turns off, which a style it is based on may turn on: bold, italic, underline,
|
||||||
/// strike-through (`fo:font-weight="normal"`…).
|
/// strike-through (`fo:font-weight="normal"`…).
|
||||||
pub(super) off: [bool; 4],
|
pub(super) off: [bool; 4],
|
||||||
|
/// Its outline level, 1–10 (`style:default-outline-level`).
|
||||||
|
pub(super) outline: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
|
/// Reads date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
|
||||||
@@ -571,8 +607,28 @@ pub(super) fn parse_border(v: &str) -> Option<Border> {
|
|||||||
any.then_some(b)
|
any.then_some(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The properties element a table column's or row's `style:default-style` (of `family`) holds.
|
||||||
|
fn table_default_props(family: Option<&str>) -> Option<&'static str> {
|
||||||
|
match family {
|
||||||
|
Some("table-column") => Some("table-column-properties"),
|
||||||
|
Some("table-row") => Some("table-row-properties"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table column's share of its table (`style:rel-column-width`, `1*`).
|
||||||
|
fn column_share_of(a: &Attrs) -> Option<f32> {
|
||||||
|
get(a, "style:rel-column-width").and_then(|v| v.trim().strip_suffix('*')?.trim().parse::<f32>().ok()).filter(|r| r.is_finite() && *r > 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
/// A property element `name` (`q` in full) of the style being read, into `s`: true when it is
|
/// A property element `name` (`q` in full) of the style being read, into `s`: true when it is
|
||||||
/// one (paragraph, table, column, cell, section and graphic properties, tab stops).
|
/// one (paragraph, table, column, cell, section and graphic properties, tab stops).
|
||||||
|
/// What `fo:background-color` says of a table or row: a colour, or none (`transparent`).
|
||||||
|
fn table_background_of(a: &Attrs) -> Option<Option<Rgb>> {
|
||||||
|
let bg = get(a, "fo:background-color")?;
|
||||||
|
if bg.trim() == "transparent" { Some(None) } else { model::parse_color(bg).map(Some) }
|
||||||
|
}
|
||||||
|
|
||||||
fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
||||||
match name {
|
match name {
|
||||||
"paragraph-properties" => paragraph_properties(a, s),
|
"paragraph-properties" => paragraph_properties(a, s),
|
||||||
@@ -599,6 +655,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
|||||||
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
|
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
|
||||||
}
|
}
|
||||||
// §20.300 `table:align`: `margins` fills the text width, as no alignment does.
|
// §20.300 `table:align`: `margins` fills the text width, as no alignment does.
|
||||||
|
s.table_background = table_background_of(a).or(s.table_background);
|
||||||
s.table_align = match get(a, "table:align").map(str::trim) {
|
s.table_align = match get(a, "table:align").map(str::trim) {
|
||||||
Some("left") => Some(Align::Left),
|
Some("left") => Some(Align::Left),
|
||||||
Some("center") => Some(Align::Center),
|
Some("center") => Some(Align::Center),
|
||||||
@@ -620,6 +677,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"table-row-properties" => {
|
"table-row-properties" => {
|
||||||
|
s.table_background = table_background_of(a).or(s.table_background);
|
||||||
if let Some(h) = get(a, "style:row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
if let Some(h) = get(a, "style:row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
||||||
s.row_height = Some((h.min(MAX_LEN), true));
|
s.row_height = Some((h.min(MAX_LEN), true));
|
||||||
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
|
||||||
@@ -633,6 +691,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
|
|||||||
if let Some(w) = get(a, "style:column-width").and_then(length) {
|
if let Some(w) = get(a, "style:column-width").and_then(length) {
|
||||||
s.column_width = Some(w);
|
s.column_width = Some(w);
|
||||||
}
|
}
|
||||||
|
if let Some(r) = column_share_of(a) {
|
||||||
|
s.column_share = Some(r);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"table-cell-properties" => table_cell_properties(a, s),
|
"table-cell-properties" => table_cell_properties(a, s),
|
||||||
"section-properties" => {
|
"section-properties" => {
|
||||||
@@ -677,7 +738,7 @@ pub(super) fn writing_direction(m: &str) -> wordcraft_doc::props::TextDirection
|
|||||||
/// A table-cell style's `style:table-cell-properties` (ODF 1.3 §17.18): background, borders,
|
/// A table-cell style's `style:table-cell-properties` (ODF 1.3 §17.18): background, borders,
|
||||||
/// padding and vertical alignment.
|
/// padding and vertical alignment.
|
||||||
fn table_cell_properties(a: &Attrs, s: &mut StyleInfo) {
|
fn table_cell_properties(a: &Attrs, s: &mut StyleInfo) {
|
||||||
if let Some(c) = get(a, "fo:background-color").and_then(model::parse_color) {
|
if let Some(c) = table_background_of(a) {
|
||||||
s.cell_background = Some(c);
|
s.cell_background = Some(c);
|
||||||
}
|
}
|
||||||
cell_borders_and_padding(a, s);
|
cell_borders_and_padding(a, s);
|
||||||
@@ -792,13 +853,18 @@ fn text_properties(a: &Attrs, style: &mut StyleInfo, faces: &HashMap<String, Str
|
|||||||
let weight = weight.trim();
|
let weight = weight.trim();
|
||||||
weight.eq_ignore_ascii_case("bold") || weight.parse::<u32>().is_ok_and(|n| n >= 600)
|
weight.eq_ignore_ascii_case("bold") || weight.parse::<u32>().is_ok_and(|n| n >= 600)
|
||||||
};
|
};
|
||||||
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks.
|
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks, to turn
|
||||||
if let Some(weight) = get(a, "fo:font-weight").or_else(|| get(a, "style:font-weight-asian")).or_else(|| get(a, "style:font-weight-complex")) {
|
// it on: one of those saying normal says nothing of Latin text.
|
||||||
|
if let Some(weight) = get(a, "fo:font-weight") {
|
||||||
fmt.bold = Some(is_bold(weight));
|
fmt.bold = Some(is_bold(weight));
|
||||||
|
} else if get(a, "style:font-weight-asian").or_else(|| get(a, "style:font-weight-complex")).is_some_and(is_bold) {
|
||||||
|
fmt.bold = Some(true);
|
||||||
}
|
}
|
||||||
let is_italic = |value: &str| value.trim().eq_ignore_ascii_case("italic") || value.trim().eq_ignore_ascii_case("oblique");
|
let is_italic = |value: &str| value.trim().eq_ignore_ascii_case("italic") || value.trim().eq_ignore_ascii_case("oblique");
|
||||||
if let Some(value) = get(a, "fo:font-style").or_else(|| get(a, "style:font-style-asian")).or_else(|| get(a, "style:font-style-complex")) {
|
if let Some(value) = get(a, "fo:font-style") {
|
||||||
fmt.italic = Some(is_italic(value));
|
fmt.italic = Some(is_italic(value));
|
||||||
|
} else if get(a, "style:font-style-asian").or_else(|| get(a, "style:font-style-complex")).is_some_and(is_italic) {
|
||||||
|
fmt.italic = Some(true);
|
||||||
}
|
}
|
||||||
let is_not_none = |value: &str| !value.trim().eq_ignore_ascii_case("none");
|
let is_not_none = |value: &str| !value.trim().eq_ignore_ascii_case("none");
|
||||||
if let Some(value) = get(a, "style:text-underline-style").or_else(|| get(a, "style:text-underline-type")) {
|
if let Some(value) = get(a, "style:text-underline-style").or_else(|| get(a, "style:text-underline-type")) {
|
||||||
@@ -1039,6 +1105,8 @@ pub(super) struct Styles {
|
|||||||
pub(super) default_tab: Option<f32>,
|
pub(super) default_tab: Option<f32>,
|
||||||
/// The Asian and complex-script fonts and languages of `style:default-style` for paragraphs.
|
/// The Asian and complex-script fonts and languages of `style:default-style` for paragraphs.
|
||||||
pub(super) default_scripts: wordcraft_doc::props::CharProps,
|
pub(super) default_scripts: wordcraft_doc::props::CharProps,
|
||||||
|
/// The language the default paragraph style states.
|
||||||
|
pub(super) default_lang: Option<String>,
|
||||||
/// Pair kerning of `style:default-style` for paragraphs (`style:letter-kerning`, §20.317;
|
/// Pair kerning of `style:default-style` for paragraphs (`style:letter-kerning`, §20.317;
|
||||||
/// off unless it says so).
|
/// off unless it says so).
|
||||||
pub(super) default_kern: bool,
|
pub(super) default_kern: bool,
|
||||||
@@ -1048,6 +1116,8 @@ pub(super) struct Styles {
|
|||||||
default_color: Option<wordcraft_doc::props::Rgb>,
|
default_color: Option<wordcraft_doc::props::Rgb>,
|
||||||
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
|
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
|
||||||
default_graphic: super::frames::Graphic,
|
default_graphic: super::frames::Graphic,
|
||||||
|
/// `style:default-style` for table columns: the share of a column whose style gives none.
|
||||||
|
default_column_share: Option<f32>,
|
||||||
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
|
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
|
||||||
default_row_keep: Option<bool>,
|
default_row_keep: Option<bool>,
|
||||||
/// The named dashes and gradients graphic styles use.
|
/// The named dashes and gradients graphic styles use.
|
||||||
@@ -1057,12 +1127,23 @@ pub(super) struct Styles {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Styles {
|
impl Styles {
|
||||||
|
/// A table column's or row's `style:default-style` properties (`props` names which): what a
|
||||||
|
/// column or row whose own style doesn't say falls back on.
|
||||||
|
fn read_table_default(&mut self, props: &str, a: &Attrs) {
|
||||||
|
if props == "table-row-properties" {
|
||||||
|
self.default_row_keep = get(a, "fo:keep-together").map(|k| k.trim() == "always");
|
||||||
|
} else {
|
||||||
|
self.default_column_share = column_share_of(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn read(&mut self, xml: &[u8], auto_section_is_auto: bool) {
|
pub(super) fn read(&mut self, xml: &[u8], auto_section_is_auto: bool) {
|
||||||
let mut in_auto = false;
|
let mut in_auto = false;
|
||||||
let mut cur: Option<(String, StyleInfo)> = None;
|
let mut cur: Option<(String, StyleInfo)> = None;
|
||||||
let mut in_default = false;
|
let mut in_default = false;
|
||||||
let mut in_graphic_default = false;
|
let mut in_graphic_default = false;
|
||||||
let mut in_row_default = false;
|
// In a table column's or row's `style:default-style`: the properties element it reads.
|
||||||
|
let mut in_table_default: Option<&str> = None;
|
||||||
let mut dates = DateStyles::default();
|
let mut dates = DateStyles::default();
|
||||||
let mut list: Option<(String, Vec<ListLevel>)> = None;
|
let mut list: Option<(String, Vec<ListLevel>)> = None;
|
||||||
// The list or outline level being read.
|
// The list or outline level being read.
|
||||||
@@ -1091,12 +1172,10 @@ impl Styles {
|
|||||||
"default-style" if q.starts_with("style:") => {
|
"default-style" if q.starts_with("style:") => {
|
||||||
in_default = get(&a, "style:family") == Some("paragraph");
|
in_default = get(&a, "style:family") == Some("paragraph");
|
||||||
in_graphic_default = get(&a, "style:family") == Some("graphic");
|
in_graphic_default = get(&a, "style:family") == Some("graphic");
|
||||||
in_row_default = get(&a, "style:family") == Some("table-row");
|
in_table_default = table_default_props(get(&a, "style:family"));
|
||||||
}
|
}
|
||||||
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
|
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
|
||||||
"table-row-properties" if in_row_default && cur.is_none() => {
|
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
|
||||||
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
|
|
||||||
}
|
|
||||||
"text-properties" if in_default && cur.is_none() => {
|
"text-properties" if in_default && cur.is_none() => {
|
||||||
self.default_font = default_font(&a, &self.faces);
|
self.default_font = default_font(&a, &self.faces);
|
||||||
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
|
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
|
||||||
@@ -1105,6 +1184,7 @@ impl Styles {
|
|||||||
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
|
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
|
||||||
self.default_color = get(&a, "fo:color").and_then(model::parse_color);
|
self.default_color = get(&a, "fo:color").and_then(model::parse_color);
|
||||||
script_properties(&a, &mut self.default_scripts, &self.faces);
|
script_properties(&a, &mut self.default_scripts, &self.faces);
|
||||||
|
self.default_lang = language(&a);
|
||||||
}
|
}
|
||||||
"paragraph-properties" if in_default && cur.is_none() => {
|
"paragraph-properties" if in_default && cur.is_none() => {
|
||||||
paragraph_spacing(&a, &mut self.default_spacing);
|
paragraph_spacing(&a, &mut self.default_spacing);
|
||||||
@@ -1163,7 +1243,7 @@ impl Styles {
|
|||||||
},
|
},
|
||||||
Ev::End(q) => match local(&q) {
|
Ev::End(q) => match local(&q) {
|
||||||
"automatic-styles" => in_auto = false,
|
"automatic-styles" => in_auto = false,
|
||||||
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
|
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_table_default) = (false, false, None),
|
||||||
"style" if q.starts_with("style:") => {
|
"style" if q.starts_with("style:") => {
|
||||||
if let Some((n, s)) = cur.take()
|
if let Some((n, s)) = cur.take()
|
||||||
&& self.map.len() < 100_000
|
&& self.map.len() < 100_000
|
||||||
@@ -1202,6 +1282,12 @@ impl Styles {
|
|||||||
.map(|v| v.min(1638.0))
|
.map(|v| v.min(1638.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The document's language: its Standard style's, else its default paragraph style's. (Written
|
||||||
|
/// from the model, Standard is empty and the default style says it for both.)
|
||||||
|
pub(super) fn document_lang(&self) -> Option<String> {
|
||||||
|
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.extra.lang.clone()).or_else(|| self.default_lang.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// The document's default font: the Standard paragraph style's, else the default style's.
|
/// The document's default font: the Standard paragraph style's, else the default style's.
|
||||||
pub(super) fn doc_font(&self) -> Option<String> {
|
pub(super) fn doc_font(&self) -> Option<String> {
|
||||||
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone())
|
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone())
|
||||||
@@ -1297,6 +1383,32 @@ impl Styles {
|
|||||||
let chain = self.chain(name);
|
let chain = self.chain(name);
|
||||||
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
|
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
|
||||||
let mut f = base.clone();
|
let mut f = base.clone();
|
||||||
|
// What the span's character style says is its own: its paragraph turning that off doesn't
|
||||||
|
// reach past it (ODF: the span's style wins).
|
||||||
|
if let Some(x) = f.extra.as_deref_mut() {
|
||||||
|
use wordcraft_doc::props::Underline;
|
||||||
|
for (_, s) in chain.iter().skip(at) {
|
||||||
|
let (d, e) = (&s.fmt, &s.fmt.extra);
|
||||||
|
if d.bold.is_some() && x.bold == Some(false) {
|
||||||
|
x.bold = None;
|
||||||
|
}
|
||||||
|
if d.italic.is_some() && x.italic == Some(false) {
|
||||||
|
x.italic = None;
|
||||||
|
}
|
||||||
|
if d.underline.is_some() && x.underline == Some(Underline::None) {
|
||||||
|
x.underline = None;
|
||||||
|
}
|
||||||
|
if (d.strike.is_some() || e.double_strike.is_some()) && x.strike == Some(false) {
|
||||||
|
(x.strike, x.double_strike) = (None, None);
|
||||||
|
}
|
||||||
|
if e.caps.is_some() && x.caps == Some(false) {
|
||||||
|
x.caps = None;
|
||||||
|
}
|
||||||
|
if e.small_caps.is_some() && x.small_caps == Some(false) {
|
||||||
|
x.small_caps = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (_, s) in chain.iter().take(at).rev() {
|
for (_, s) in chain.iter().take(at).rev() {
|
||||||
s.fmt.apply(&mut f);
|
s.fmt.apply(&mut f);
|
||||||
}
|
}
|
||||||
@@ -1340,6 +1452,12 @@ impl Styles {
|
|||||||
self.chain(name).iter().find_map(|(_, s)| s.column_width)
|
self.chain(name).iter().find_map(|(_, s)| s.column_width)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A table column's share of its table (`style:rel-column-width`): its style's, whole chain,
|
||||||
|
/// else the default style's.
|
||||||
|
pub(super) fn column_share(&self, name: Option<&str>) -> Option<f32> {
|
||||||
|
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.column_share)).or(self.default_column_share)
|
||||||
|
}
|
||||||
|
|
||||||
/// A table style's alignment, width (points) and relative width (percent), whole chain.
|
/// A table style's alignment, width (points) and relative width (percent), whole chain.
|
||||||
/// Whether a table style starts a page (`fo:break-before="page"`, whole chain).
|
/// Whether a table style starts a page (`fo:break-before="page"`, whole chain).
|
||||||
pub(super) fn table_breaks(&self, name: &str) -> bool {
|
pub(super) fn table_breaks(&self, name: &str) -> bool {
|
||||||
@@ -1379,8 +1497,13 @@ impl Styles {
|
|||||||
self.chain(name).iter().find_map(|(_, s)| s.cell_picture.clone())
|
self.chain(name).iter().find_map(|(_, s)| s.cell_picture.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn table_background(&self, name: &str) -> Option<Rgb> {
|
||||||
|
self.chain(name).iter().find_map(|(_, s)| s.table_background).flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background of a table-cell style (whole chain).
|
||||||
pub(super) fn cell_background(&self, name: &str) -> Option<Rgb> {
|
pub(super) fn cell_background(&self, name: &str) -> Option<Rgb> {
|
||||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
|
self.chain(name).iter().find_map(|(_, s)| s.cell_background).flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Borders of a table-cell style (whole chain).
|
/// Borders of a table-cell style (whole chain).
|
||||||
@@ -1507,6 +1630,13 @@ impl Styles {
|
|||||||
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
|
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
|
||||||
fmt.size = self.default_size;
|
fmt.size = self.default_size;
|
||||||
}
|
}
|
||||||
|
// And its language, likewise.
|
||||||
|
if s.parent.is_none()
|
||||||
|
&& let Some(lang) = self.default_lang.as_ref().filter(|l| self.document_lang().as_ref() != Some(*l))
|
||||||
|
&& fmt.extra.as_ref().is_none_or(|x| x.lang.is_none())
|
||||||
|
{
|
||||||
|
fmt.extra.get_or_insert_with(Default::default).lang = Some(lang.clone());
|
||||||
|
}
|
||||||
let look = ParaLook {
|
let look = ParaLook {
|
||||||
align: s.align,
|
align: s.align,
|
||||||
keep_next: s.keep_next == Some(true),
|
keep_next: s.keep_next == Some(true),
|
||||||
@@ -1521,13 +1651,16 @@ impl Styles {
|
|||||||
};
|
};
|
||||||
let name = s.display.clone().unwrap_or_else(|| odt.replace("_20_", " "));
|
let name = s.display.clone().unwrap_or_else(|| odt.replace("_20_", " "));
|
||||||
let off = [s.fmt.bold, s.fmt.italic, s.fmt.underline, s.fmt.strike].map(|v| v == Some(false));
|
let off = [s.fmt.bold, s.fmt.italic, s.fmt.underline, s.fmt.strike].map(|v| v == Some(false));
|
||||||
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off })
|
let outline = s.outline.filter(|o| (1..=10).contains(o));
|
||||||
|
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off, outline })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn para(&self, name: &str) -> ParaLook {
|
pub(super) fn para(&self, name: &str) -> ParaLook {
|
||||||
let chain = self.chain(name);
|
let chain = self.chain(name);
|
||||||
let mut kind = Kind::Normal;
|
let mut kind = Kind::Normal;
|
||||||
let mut kind_at = chain.len();
|
let mut kind_at = chain.len();
|
||||||
|
// The nearest outline level; a style with one is no kind's style: its look is its own.
|
||||||
|
let mut outline = None;
|
||||||
for (i, (n, s)) in chain.iter().enumerate() {
|
for (i, (n, s)) in chain.iter().enumerate() {
|
||||||
kind_at = i;
|
kind_at = i;
|
||||||
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
|
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
|
||||||
@@ -1546,12 +1679,12 @@ impl Styles {
|
|||||||
if kind != Kind::Normal {
|
if kind != Kind::Normal {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Some(o) = s.outline.filter(|o| (1..=10).contains(o)) {
|
outline = outline.or(s.outline.filter(|o| (1..=10).contains(o)));
|
||||||
kind = Kind::Heading(o.min(6));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
kind_at = chain.len();
|
kind_at = chain.len();
|
||||||
}
|
}
|
||||||
|
if let Some(o) = outline {
|
||||||
|
kind = Kind::Heading(o.min(6));
|
||||||
|
}
|
||||||
// The nearest custom style: its formatting stays in it; the automatic styles over it give
|
// The nearest custom style: its formatting stays in it; the automatic styles over it give
|
||||||
// the paragraph's own.
|
// the paragraph's own.
|
||||||
let named_at = chain.iter().take(kind_at).position(|(n, _)| self.is_named(n));
|
let named_at = chain.iter().take(kind_at).position(|(n, _)| self.is_named(n));
|
||||||
|
|||||||
@@ -205,6 +205,81 @@ fn object_without_mathml_falls_back_to_its_replacement_image() {
|
|||||||
assert!(imgs[0].data.as_slice() == png.as_slice());
|
assert!(imgs[0].data.as_slice() == png.as_slice());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A picture of `w` by `h` pixels, as a PNG.
|
||||||
|
fn png_of(w: u32, h: u32) -> Vec<u8> {
|
||||||
|
let mut png = Cursor::new(Vec::new());
|
||||||
|
image::RgbImage::new(w, h).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||||
|
png.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_stated_on_one_side_keeps_its_own_proportions() {
|
||||||
|
// A hundred times as tall as wide: only the cap on a page's size may shrink it, in proportion.
|
||||||
|
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="500pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png_of(10, 1000))]);
|
||||||
|
let flow = parse(&pkg).unwrap();
|
||||||
|
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||||
|
assert!((img.w - 15.84).abs() < 0.01 && (img.h - 1584.0).abs() < 0.01, "{} {}", img.w, img.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_objects_picture_with_no_stated_size_is_fitted_to_the_text() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let frame =
|
||||||
|
r#"<draw:frame text:anchor-type="as-char"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let pkg = zip_of(&[
|
||||||
|
("content.xml", content.as_bytes()),
|
||||||
|
("styles.xml", styles.as_bytes()),
|
||||||
|
("Object 1/content.xml", b"<office:spreadsheet/>"),
|
||||||
|
("Pictures/a.png", &png_of(1000, 500)),
|
||||||
|
]);
|
||||||
|
let flow = parse(&pkg).unwrap();
|
||||||
|
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||||
|
assert!(img.object.is_some(), "still the object");
|
||||||
|
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_stating_a_size_of_nothing_is_fitted_to_the_text() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="0pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||||
|
let flow = parse(&pkg).unwrap();
|
||||||
|
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||||
|
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_in_line_raised_or_turned_with_no_size_is_fitted_to_the_text() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let auto = r#"<office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties style:vertical-pos="middle" style:vertical-rel="char"/></style:style></office:automatic-styles>"#;
|
||||||
|
for attrs in [r#" draw:style-name="fr1""#, r#" draw:transform="rotate (0.5) translate (0cm 0cm)""#] {
|
||||||
|
let frame = format!(r#"<draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}>{auto}<office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
|
||||||
|
let flow = parse(&pkg).unwrap();
|
||||||
|
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
|
||||||
|
assert!(img.w <= 306.5, "{attrs}: {} {}", img.w, img.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
pub(super) const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
|
||||||
|
|
||||||
/// A package with `content.xml` and, when given, `styles.xml`.
|
/// A package with `content.xml` and, when given, `styles.xml`.
|
||||||
@@ -1269,7 +1344,7 @@ fn the_default_styles_font_size_is_the_documents() {
|
|||||||
let s = entry(&crate::odt::tests::export_model(&d).unwrap(), "styles.xml");
|
let s = entry(&crate::odt::tests::export_model(&d).unwrap(), "styles.xml");
|
||||||
assert!(
|
assert!(
|
||||||
s.contains(r#"<style:text-properties fo:font-family="'Liberation Serif'" fo:font-size="11pt""#)
|
s.contains(r#"<style:text-properties fo:font-family="'Liberation Serif'" fo:font-size="11pt""#)
|
||||||
|| s.contains(r#"fo:font-size="11pt" style:letter-kerning"#),
|
|| s.contains(r#"fo:font-size="11pt" style:font-size-asian="11pt""#),
|
||||||
"{s}"
|
"{s}"
|
||||||
);
|
);
|
||||||
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||||
|
|||||||
@@ -285,6 +285,431 @@ fn objects_in_hidden_text_are_kept() {
|
|||||||
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
|
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A paragraph in style `style` holding `span`, with `auto` among its automatic styles.
|
||||||
|
fn off_doc(styles: &str, style: &str, auto: &str, span: &str) -> Document {
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text><text:p text:style-name="{style}">{span}</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let styles = format!("<office:document-styles {NS_T}><office:styles>{styles}</office:styles></office:document-styles>");
|
||||||
|
import(&crate::odt::tests::package(&content, Some(&styles))).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_turning_off_what_its_style_turns_on_stays_off() {
|
||||||
|
// A word not bold in a bold heading: it showed bold, on screen and after a save.
|
||||||
|
let on = r#"fo:font-weight="bold" fo:font-style="italic" style:text-underline-style="solid" style:text-line-through-style="solid" fo:font-variant="small-caps" fo:text-transform="uppercase""#;
|
||||||
|
let off = r#"fo:font-weight="normal" fo:font-style="normal" style:text-underline-style="none" style:text-line-through-style="none" fo:font-variant="normal" fo:text-transform="none""#;
|
||||||
|
let styles = format!(r#"<style:style style:name="Laut" style:family="paragraph"><style:text-properties {on}/></style:style>"#);
|
||||||
|
let auto = format!(r#"<style:style style:name="T1" style:family="text"><style:text-properties {off}/></style:style>"#);
|
||||||
|
let check = |d: &Document, what: &str| {
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let shown = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props);
|
||||||
|
assert_eq!(p.text, "onoff", "{what}");
|
||||||
|
let (a, b) = (shown(0), shown(1));
|
||||||
|
assert_eq!((a.bold, a.italic, a.strike, a.small_caps, a.caps), (true, true, true, true, true), "{what}: the style's");
|
||||||
|
assert!(a.underline != Underline::None, "{what}");
|
||||||
|
assert_eq!((b.bold, b.italic, b.strike, b.small_caps, b.caps), (false, false, false, false, false), "{what}: turned off");
|
||||||
|
assert_eq!(b.underline, Underline::None, "{what}");
|
||||||
|
};
|
||||||
|
let d = off_doc(&styles, "Laut", &auto, r#"on<text:span text:style-name="T1">off</text:span>"#);
|
||||||
|
check(&d, "read");
|
||||||
|
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
|
||||||
|
check(&import(&export(&d).unwrap()).unwrap(), "written as read");
|
||||||
|
// Turned back on inside it: on.
|
||||||
|
let auto = format!(
|
||||||
|
r#"{auto}<style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#
|
||||||
|
);
|
||||||
|
let d = off_doc(&styles, "Laut", &auto, r#"<text:span text:style-name="T1"><text:span text:style-name="T2">x</text:span></text:span>"#);
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||||
|
assert!(x.bold);
|
||||||
|
assert!(x.underline != Underline::None, "{x:?}");
|
||||||
|
assert!(!x.italic, "still off");
|
||||||
|
// A character style's: off in the automatic style based on it.
|
||||||
|
let styles = r#"<style:style style:name="Zeichen" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#;
|
||||||
|
let auto = r#"<style:style style:name="T3" style:family="text" style:parent-style-name="Zeichen"><style:text-properties fo:font-weight="normal" style:text-underline-style="none"/></style:style>"#;
|
||||||
|
let d = off_doc(styles, "Standard", auto, r#"<text:span text:style-name="T3">x</text:span>"#);
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||||
|
assert_eq!((x.bold, x.underline), (false, Underline::None), "{x:?}");
|
||||||
|
// Text nothing turned on says nothing.
|
||||||
|
let d = off_doc("", "Standard", "", "plain");
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert!(!c.contains(r#"fo:font-weight="normal""#) && !c.contains(r#"style:text-underline-style="none""#), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A weight or slant only for Asian or complex-script text says nothing of Latin text when it is
|
||||||
|
/// normal: the heading's bold stays, on screen and saved. Latin text's own "normal" still counts.
|
||||||
|
#[test]
|
||||||
|
fn an_asian_or_complex_normal_doesnt_turn_latin_text_off() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let open = |attr: &str| {
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {attr}/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">Bold <text:span text:style-name="T1">word</text:span></text:h></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
import(&package(&content, Some(&styles))).unwrap()
|
||||||
|
};
|
||||||
|
let run = |d: &Document| d.body[0].as_para().unwrap().runs.last().unwrap().props.clone();
|
||||||
|
for attr in [
|
||||||
|
r#"style:font-weight-complex="normal""#,
|
||||||
|
r#"style:font-weight-asian="normal""#,
|
||||||
|
r#"style:font-style-complex="normal""#,
|
||||||
|
r#"style:font-style-asian="normal""#,
|
||||||
|
] {
|
||||||
|
let d = open(attr);
|
||||||
|
assert_eq!((run(&d).bold, run(&d).italic), (None, None), "{attr}");
|
||||||
|
let saved = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert!(!saved.contains(r#"fo:font-weight="normal""#) && !saved.contains(r#"fo:font-style="normal""#), "{attr}: {saved}");
|
||||||
|
}
|
||||||
|
assert_eq!(run(&open(r#"fo:font-weight="normal""#)).bold, Some(false));
|
||||||
|
assert_eq!(run(&open(r#"fo:font-style="normal""#)).italic, Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strike-through turned off over a double one is off, not the double one again.
|
||||||
|
#[test]
|
||||||
|
fn strike_turned_off_over_a_double_strike_stays_off() {
|
||||||
|
let double = r#"<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="double"/>"#;
|
||||||
|
let none = r#"<style:text-properties style:text-line-through-style="none"/>"#;
|
||||||
|
let check = |d: &Document, what: &str| {
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||||
|
assert_eq!((c.strike, c.double_strike), (false, false), "{what}");
|
||||||
|
};
|
||||||
|
// A style over its parent.
|
||||||
|
let styles = format!(
|
||||||
|
r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent">{none}</style:style>"#
|
||||||
|
);
|
||||||
|
let d = off_doc(&styles, "Kid", "", "One");
|
||||||
|
check(&d, "a style, read");
|
||||||
|
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a style, written from the model");
|
||||||
|
// A span over its paragraph's style.
|
||||||
|
let styles = format!(r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style>"#);
|
||||||
|
let auto = format!(r#"<style:style style:name="T1" style:family="text">{none}</style:style>"#);
|
||||||
|
let d = off_doc(&styles, "Parent", &auto, r#"<text:span text:style-name="T1">One</text:span>"#);
|
||||||
|
check(&d, "a span, read");
|
||||||
|
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a span, written from the model");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A span's character style is its own: a paragraph turning bold or italic off doesn't reach past it.
|
||||||
|
#[test]
|
||||||
|
fn a_spans_character_style_wins_over_its_paragraph_turning_off() {
|
||||||
|
let styles = r#"<style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style>"#;
|
||||||
|
let auto = r#"<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-weight="normal" fo:font-style="normal"/></style:style>"#;
|
||||||
|
let d = off_doc(styles, "P1", auto, r#"<text:span text:style-name="Strong_20_Emphasis">One</text:span>"#);
|
||||||
|
let check = |d: &Document, what: &str| {
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||||
|
assert_eq!((c.bold, c.italic), (true, true), "{what}: {:?}", p.runs.last().unwrap().props);
|
||||||
|
};
|
||||||
|
check(&d, "read");
|
||||||
|
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `default` as a document's default paragraph style's text properties, with `standard` as its
|
||||||
|
/// Standard style's.
|
||||||
|
fn with_default_text(default: &str, standard: &str) -> Vec<u8> {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties {default}/></style:default-style><style:style style:name="Standard" style:family="paragraph"><style:text-properties {standard}/></style:style></office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
crate::odt::tests::package(&content, Some(&styles))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `styles`' default paragraph style.
|
||||||
|
fn default_style(styles: &str) -> &str {
|
||||||
|
let at = styles.find(r#"<style:default-style style:family="paragraph">"#).expect("a default paragraph style");
|
||||||
|
styles[at..].split("</style:default-style>").next().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document's language is the one its file states, and a model-written save says it.
|
||||||
|
#[test]
|
||||||
|
fn the_default_language_is_the_files_and_written_back_from_the_model() {
|
||||||
|
let d = import(&with_default_text(r#"fo:language="de" fo:country="DE""#, "")).unwrap();
|
||||||
|
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||||
|
assert_eq!(d.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("de-DE"));
|
||||||
|
// Written from the model (a style or list definition changed), it is still the file's: without
|
||||||
|
// it LibreOffice shows the document in its own default language.
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||||
|
assert!(default_style(&styles).contains(r#"fo:language="de" fo:country="DE""#), "{styles}");
|
||||||
|
assert!(!styles.contains(r#"fo:language="en""#), "no style says English: {styles}");
|
||||||
|
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||||
|
// Saved as read, the styles WordCraft adds beside the file's don't say English either.
|
||||||
|
let styles = crate::odt::tests::entry(&export(&d).unwrap(), "styles.xml");
|
||||||
|
assert!(!styles.contains(r#"fo:language="en""#), "{styles}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document's language as WordCraft has it is Normal's (a Word file's Normal may state
|
||||||
|
/// another than its defaults): that is what the default style says when written from the model.
|
||||||
|
#[test]
|
||||||
|
fn the_documents_language_written_is_normals() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let mut d = Document::new();
|
||||||
|
Arc::make_mut(&mut d.styles).default_chr.lang = Some("en-US".into());
|
||||||
|
Arc::make_mut(&mut d.styles).get_mut("Normal").unwrap().chr.lang = Some("fr-FR".into());
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let s = crate::odt::tests::styles_of(&saved);
|
||||||
|
let def = s.split(r#"<style:default-style style:family="paragraph""#).nth(1).and_then(|t| t.split("</style:default-style>").next()).unwrap();
|
||||||
|
assert!(def.contains(r#"fo:language="fr""#) && def.contains(r#"fo:country="FR""#), "{def}");
|
||||||
|
let back = import(&saved).unwrap();
|
||||||
|
assert_eq!(back.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("fr-FR"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file stating no language, or `none` or `zxx`, has none: not the English of new documents.
|
||||||
|
#[test]
|
||||||
|
fn a_file_stating_no_language_has_none_and_writes_none() {
|
||||||
|
let d = import(&with_default_text("", "")).unwrap();
|
||||||
|
assert_eq!(d.styles.default_chr.lang, None, "not the English of WordCraft's new documents");
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
assert!(!crate::odt::tests::entry(&saved, "styles.xml").contains("fo:language"));
|
||||||
|
assert_eq!(import(&saved).unwrap().styles.default_chr.lang, None);
|
||||||
|
// `none` and `zxx` are no language.
|
||||||
|
for none in ["none", "zxx"] {
|
||||||
|
let d = import(&with_default_text(&format!(r#"fo:language="{none}" fo:country="none""#), "")).unwrap();
|
||||||
|
assert_eq!(d.styles.default_chr.lang, None, "{none}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A language only Standard states is the document's, and a model-written save says it in the
|
||||||
|
/// default style.
|
||||||
|
#[test]
|
||||||
|
fn a_language_only_the_standard_style_states_survives_a_rewrite() {
|
||||||
|
let d = import(&with_default_text("", r#"fo:language="nb" fo:country="NO""#)).unwrap();
|
||||||
|
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("nb-NO"));
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||||
|
assert!(default_style(&styles).contains(r#"fo:language="nb" fo:country="NO""#), "{styles}");
|
||||||
|
let back = import(&saved).unwrap();
|
||||||
|
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("nb-NO"));
|
||||||
|
let p = back.body[0].as_para().unwrap();
|
||||||
|
let lang =
|
||||||
|
p.runs.first().and_then(|r| r.props.lang.clone()).or_else(|| back.styles.resolve_char(p.props.style.as_deref(), &Default::default()).lang);
|
||||||
|
assert_eq!(lang.as_deref(), Some("nb-NO"), "{}", crate::odt::tests::entry(&saved, "styles.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A language with a script keeps it, read and written: the document's, a style's for each kind
|
||||||
|
/// of text, and a run's.
|
||||||
|
#[test]
|
||||||
|
fn a_language_with_a_script_keeps_it() {
|
||||||
|
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#, "")).unwrap();
|
||||||
|
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||||
|
assert!(default_style(&styles).contains(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#), "{styles}");
|
||||||
|
assert!(!styles.contains(r#"fo:country="Latn""#), "{styles}");
|
||||||
|
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
|
||||||
|
|
||||||
|
let (latin, asian, complex) = (
|
||||||
|
r#"fo:language="sr" fo:script="Cyrl" fo:country="BA""#,
|
||||||
|
r#"style:language-asian="zh" style:script-asian="Hant" style:country-asian="TW""#,
|
||||||
|
r#"style:language-complex="az" style:script-complex="Arab" style:country-complex="IR""#,
|
||||||
|
);
|
||||||
|
let run = r#"fo:language="bs" fo:script="Latn" fo:country="BA""#;
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties {latin} {asian} {complex}/></style:style></office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {run}/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Eigen">Own <text:span text:style-name="T1">span</text:span></text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let check = |d: &wordcraft_doc::Document| {
|
||||||
|
let chr = &d.styles.get("Eigen").unwrap().chr;
|
||||||
|
assert_eq!(
|
||||||
|
(chr.lang.as_deref(), chr.lang_ea.as_deref(), chr.lang_bidi.as_deref()),
|
||||||
|
(Some("sr-Cyrl-BA"), Some("zh-Hant-TW"), Some("az-Arab-IR"))
|
||||||
|
);
|
||||||
|
assert_eq!(d.body[0].as_para().unwrap().runs.iter().find_map(|r| r.props.lang.as_deref()), Some("bs-Latn-BA"));
|
||||||
|
};
|
||||||
|
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
|
||||||
|
check(&d);
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let written = crate::odt::tests::entry(&saved, "styles.xml");
|
||||||
|
assert!(written.contains(latin) && written.contains(asian) && written.contains(complex), "{written}");
|
||||||
|
assert!(crate::odt::tests::entry(&saved, "content.xml").contains(run));
|
||||||
|
check(&import(&saved).unwrap());
|
||||||
|
|
||||||
|
// No country, a region of digits, and what isn't a script.
|
||||||
|
let attrs = crate::odt::writer::lang_attrs;
|
||||||
|
assert_eq!(attrs("zh-Hant", ""), r#" fo:language="zh" fo:script="Hant""#);
|
||||||
|
assert_eq!(attrs("es-419", ""), r#" fo:language="es" fo:country="419""#);
|
||||||
|
assert_eq!(attrs("de-DE", "-asian"), r#" style:language-asian="de" style:country-asian="DE""#);
|
||||||
|
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latin1" fo:country="RS""#, "")).unwrap();
|
||||||
|
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-RS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A style with no parent inherits the default style's language, not Standard's (the document's
|
||||||
|
/// here), and one based on Standard takes Standard's.
|
||||||
|
#[test]
|
||||||
|
fn a_style_with_no_parent_has_the_default_styles_language() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="en" fo:country="US"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:language="de" fo:country="DE"/></style:style><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph"/><style:style style:name="Gesagt" style:display-name="Gesagt" style:family="paragraph"><style:text-properties fo:language="fr" fo:country="FR"/></style:style><style:style style:name="Kind" style:display-name="Kind" style:family="paragraph" style:parent-style-name="Standard"/></office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:body><office:text><text:p text:style-name="Eigen">a</text:p><text:p text:style-name="Gesagt">b</text:p><text:p text:style-name="Kind">c</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let check = |d: &wordcraft_doc::Document| {
|
||||||
|
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
|
||||||
|
let lang = |id: &str| d.styles.resolve_char(Some(id), &Default::default()).lang;
|
||||||
|
assert_eq!(lang("Eigen").as_deref(), Some("en-US"), "no parent: the default style's");
|
||||||
|
assert_eq!(lang("Gesagt").as_deref(), Some("fr-FR"), "its own");
|
||||||
|
assert_eq!(lang("Kind").as_deref(), Some("de-DE"), "based on Standard");
|
||||||
|
};
|
||||||
|
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
|
||||||
|
check(&d);
|
||||||
|
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WordCraft's own documents say their language, so a reader doesn't use its own default.
|
||||||
|
#[test]
|
||||||
|
fn a_new_documents_language_is_stated() {
|
||||||
|
let styles = crate::odt::tests::entry(&export(&wordcraft_doc::Document::new()).unwrap(), "styles.xml");
|
||||||
|
assert!(default_style(&styles).contains(r#"fo:language="en" fo:country="US""#), "{styles}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_size_is_every_scripts_size() {
|
||||||
|
// Chinese text set to 24 pt stayed at the default size in LibreOffice: only the Latin size was
|
||||||
|
// written.
|
||||||
|
let mut d = Document::new();
|
||||||
|
let mut p = Paragraph::with_text("漢字", CharProps { size: Some(24.0), ..Default::default() });
|
||||||
|
p.props = Default::default();
|
||||||
|
d.body = vec![para_block(p)];
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let c = crate::odt::tests::content_of(&saved);
|
||||||
|
assert!(c.contains(r#"fo:font-size="24pt" style:font-size-asian="24pt" style:font-size-complex="24pt""#), "{c}");
|
||||||
|
let styles = crate::odt::tests::entry(&saved, "styles.xml");
|
||||||
|
assert!(styles.contains(r#"fo:font-size="12pt" style:font-size-asian="12pt" style:font-size-complex="12pt""#), "{styles}");
|
||||||
|
// Read back every script has it.
|
||||||
|
let back = import(&saved).unwrap();
|
||||||
|
let run = &back.body[0].as_para().unwrap().runs[0].props;
|
||||||
|
assert_eq!(back.styles.resolve_char(None, run).size_cs, 24.0);
|
||||||
|
// A complex-script size of its own stays its own, written once.
|
||||||
|
let mut d = Document::new();
|
||||||
|
d.body = vec![para_block(Paragraph::with_text("x", CharProps { size: Some(24.0), size_cs: Some(30.0), ..Default::default() }))];
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert_eq!(c.matches("style:font-size-complex").count(), 1, "{c}");
|
||||||
|
assert!(c.contains(r#"style:font-size-complex="30pt""#), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text and its complex-script size stated alike, over a default style with a smaller one (as
|
||||||
|
/// LibreOffice's files have): that size stays, on screen and saved, for a run and for a heading.
|
||||||
|
#[test]
|
||||||
|
fn a_complex_size_like_the_texts_stays_over_the_default() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="14pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-size="18pt" style:font-size-complex="18pt"/></style:style></office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style></office:automatic-styles><office:body><office:text><text:p>a <text:span text:style-name="T1">big</text:span></text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">h</text:h></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||||
|
let check = |d: &Document| {
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||||
|
assert_eq!((c.size, c.size_cs), (24.0, 24.0));
|
||||||
|
let h = d.body[1].as_para().unwrap();
|
||||||
|
let c = d.styles.resolve_char(h.props.style.as_deref(), &h.runs[0].props);
|
||||||
|
assert_eq!((c.size, c.size_cs), (18.0, 18.0));
|
||||||
|
};
|
||||||
|
check(&d);
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let written = crate::odt::tests::entry(&saved, "styles.xml");
|
||||||
|
let heading = written.split(r#"style:name="Heading_20_1""#).nth(1).and_then(|t| t.split("</style:style>").next()).unwrap();
|
||||||
|
assert!(heading.contains(r#"style:font-size-complex="18pt""#) && !heading.contains(r#"style:font-size-complex="14pt""#), "{heading}");
|
||||||
|
check(&import(&saved).unwrap());
|
||||||
|
|
||||||
|
// A default stating one size for all goes with that size: Normal changed takes it along.
|
||||||
|
let same = styles.replace(r#"style:font-size-complex="14pt""#, r#"style:font-size-complex="12pt""#);
|
||||||
|
let d = import(&package(&content, Some(&same))).unwrap();
|
||||||
|
assert_eq!(d.styles.default_chr.size_cs, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complex size equal to the text's goes only where no run's complex size changes without it:
|
||||||
|
/// not under a character style over another size, nor under a size stated alone.
|
||||||
|
#[test]
|
||||||
|
fn a_complex_size_something_shows_stays() {
|
||||||
|
let open = |s: &str, auto: &str, body: &str| {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/>{s}</office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
import(&package(&content, Some(&styles))).unwrap()
|
||||||
|
};
|
||||||
|
let sizes = |d: &Document| {
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||||
|
(c.size, c.size_cs)
|
||||||
|
};
|
||||||
|
// A character style's, over a paragraph style with another complex size.
|
||||||
|
let d = open(
|
||||||
|
r#"<style:style style:name="P" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="12pt" style:font-size-complex="18pt"/></style:style><style:style style:name="C" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style>"#,
|
||||||
|
"",
|
||||||
|
r#"<text:p text:style-name="P"><text:span text:style-name="C">One</text:span></text:p>"#,
|
||||||
|
);
|
||||||
|
assert_eq!(sizes(&d), (24.0, 24.0));
|
||||||
|
let parent = r#"<style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style>"#;
|
||||||
|
// A parent's, under a child stating only a size.
|
||||||
|
let kid = format!(
|
||||||
|
r#"{parent}<style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style>"#
|
||||||
|
);
|
||||||
|
assert_eq!(sizes(&open(&kid, "", r#"<text:p text:style-name="Kid">One</text:p>"#)), (20.0, 16.0));
|
||||||
|
// A style's, under a run stating only a size.
|
||||||
|
let d = open(
|
||||||
|
parent,
|
||||||
|
r#"<style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style>"#,
|
||||||
|
r#"<text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p>"#,
|
||||||
|
);
|
||||||
|
assert_eq!(sizes(&d), (30.0, 16.0));
|
||||||
|
// Nothing shows it: one size for all, so a change of size takes it along.
|
||||||
|
let d = open(parent, "", r#"<text:p text:style-name="Parent">One</text:p>"#);
|
||||||
|
assert_eq!(sizes(&d), (16.0, 16.0));
|
||||||
|
assert_eq!(d.styles.get("Parent").unwrap().chr.size_cs, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A size stated alone, by a run or a style, under a different complex size above it: saved from the
|
||||||
|
/// model, and saved again, the complex size is still the one above, not the stated size.
|
||||||
|
#[test]
|
||||||
|
fn a_size_stated_alone_keeps_the_complex_size_above_it_when_saved() {
|
||||||
|
let sizes = |d: &Document, i: usize| {
|
||||||
|
let p = d.body[i].as_para().unwrap();
|
||||||
|
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
|
||||||
|
(c.size, c.size_cs)
|
||||||
|
};
|
||||||
|
let twice = |d: Document| {
|
||||||
|
let once = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||||
|
let again = import(&crate::odt::tests::export_model(&once).unwrap()).unwrap();
|
||||||
|
(once, again)
|
||||||
|
};
|
||||||
|
// From a file: a run stating only a size, and a style stating only a size, under a parent
|
||||||
|
// style with its own complex size.
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style></office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p><text:p text:style-name="Kid">Two</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||||
|
assert_eq!((sizes(&d, 0), sizes(&d, 1)), ((30.0, 16.0), (20.0, 16.0)));
|
||||||
|
let (once, again) = twice(d);
|
||||||
|
assert_eq!((sizes(&once, 0), sizes(&once, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved once");
|
||||||
|
assert_eq!((sizes(&again, 0), sizes(&again, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved twice");
|
||||||
|
// Built in the model (a DOCX run with `w:sz` and no `w:szCs`): the default's complex size.
|
||||||
|
let mut d = Document::new();
|
||||||
|
let styles = std::sync::Arc::make_mut(&mut d.styles);
|
||||||
|
styles.default_chr.size = Some(12.0);
|
||||||
|
styles.default_chr.size_cs = Some(16.0);
|
||||||
|
let mut p = Paragraph::with_text("x", CharProps { size: Some(30.0), ..Default::default() });
|
||||||
|
p.props = Default::default();
|
||||||
|
d.body = vec![para_block(p)];
|
||||||
|
assert_eq!(sizes(&d, 0), (30.0, 16.0));
|
||||||
|
let (once, again) = twice(d);
|
||||||
|
assert_eq!((sizes(&once, 0), sizes(&again, 0)), ((30.0, 16.0), (30.0, 16.0)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_headings_text_of_another_size_keeps_it() {
|
fn a_headings_text_of_another_size_keeps_it() {
|
||||||
// Only the size its style gives goes with the style: a smaller word in a heading came back
|
// Only the size its style gives goes with the style: a smaller word in a heading came back
|
||||||
|
|||||||
@@ -368,6 +368,65 @@ fn a_frame_keeps_its_place_in_the_stacking_order() {
|
|||||||
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
|
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_anchored_to_a_page_stays_on_that_page() {
|
||||||
|
// LibreOffice's page-anchored frames name their page. Written without it, a frame on page 3
|
||||||
|
// came back on page 1 in LibreOffice.
|
||||||
|
let frame = |attrs: &str| {
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
import(&crate::odt::tests::package(&content, None)).unwrap()
|
||||||
|
};
|
||||||
|
let d = frame(r#"text:anchor-type="page" text:anchor-page-number="3""#);
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
|
||||||
|
let back =
|
||||||
|
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()).unwrap());
|
||||||
|
assert!(back.contains(r#"text:anchor-page-number="3""#), "again: {back}");
|
||||||
|
// A frame anchored to its paragraph names no page, and junk is none.
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&frame(r#"text:anchor-type="paragraph""#)).unwrap());
|
||||||
|
assert!(!c.contains("anchor-page-number"), "{c}");
|
||||||
|
for junk in ["-1", "abc", "", "99999999999999"] {
|
||||||
|
let d = frame(&format!(r#"text:anchor-type="page" text:anchor-page-number="{junk}""#));
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert!(!c.contains("anchor-page-number"), "{junk}: {c}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page number only says something on a page anchor. On another LibreOffice ignores it, so it
|
||||||
|
/// doesn't make a drawing anchored to a page when saved.
|
||||||
|
#[test]
|
||||||
|
fn a_page_number_on_another_anchor_is_no_page_anchor() {
|
||||||
|
let saved = |styles: &str, body: &str| {
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T} {NS_D}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::package(&content, None)).unwrap()).unwrap())
|
||||||
|
};
|
||||||
|
let frame = |attrs: &str| {
|
||||||
|
format!(
|
||||||
|
r#"<text:p>One<draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame></text:p>"#
|
||||||
|
)
|
||||||
|
};
|
||||||
|
for anchor in ["paragraph", "char"] {
|
||||||
|
let c = saved("", &frame(&format!(r#"text:anchor-type="{anchor}" text:anchor-page-number="3""#)));
|
||||||
|
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{anchor}: {c}");
|
||||||
|
}
|
||||||
|
let c = saved(
|
||||||
|
"",
|
||||||
|
r#"<text:p>One<draw:line text:anchor-type="paragraph" text:anchor-page-number="3" svg:x1="1cm" svg:y1="1cm" svg:x2="3cm" svg:y2="3cm"/></text:p>"#,
|
||||||
|
);
|
||||||
|
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "a line: {c}");
|
||||||
|
// An anchor its style gives counts as one the frame states.
|
||||||
|
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="page"/></style:style>"#;
|
||||||
|
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
|
||||||
|
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
|
||||||
|
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="paragraph"/></style:style>"#;
|
||||||
|
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
|
||||||
|
assert!(!c.contains("anchor-page-number"), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
|
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
|
||||||
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
|
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
|
||||||
|
|||||||
@@ -160,6 +160,243 @@ fn a_table_starting_a_page_starts_it() {
|
|||||||
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
|
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn columns_stating_only_their_shares_keep_their_proportions() {
|
||||||
|
// LibreOffice's CV template: a table filling the margins whose columns give only
|
||||||
|
// `style:rel-column-width`. They were read as equal.
|
||||||
|
let column = |name: &str, share: &str| {
|
||||||
|
format!(
|
||||||
|
r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties style:rel-column-width="{share}"/></style:style>"#
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let table = r#"<table:table><table:table-column table:style-name="A"/><table:table-column table:style-name="B"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||||
|
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
|
||||||
|
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", "3000*")), table);
|
||||||
|
let g = grid(&d);
|
||||||
|
assert_eq!(g.len(), 2, "{g:?}");
|
||||||
|
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
|
||||||
|
// Rewritten from the model, the proportions stay.
|
||||||
|
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
|
||||||
|
let g = grid(&back);
|
||||||
|
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
|
||||||
|
// A column without a share leaves the equal split, and junk shares are none.
|
||||||
|
for junk in ["0*", "-1*", "NaN*", "1e39*", "abc"] {
|
||||||
|
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", junk)), table);
|
||||||
|
let g = grid(&d);
|
||||||
|
assert_eq!(g.len(), 2, "{junk}: {g:?}");
|
||||||
|
assert!((g[0] - g[1]).abs() < 0.01, "{junk}: {g:?}");
|
||||||
|
}
|
||||||
|
// A table filling the margins with a width stated beside each share keeps the widths: files
|
||||||
|
// give them in proportion, and the table is fitted to the margins.
|
||||||
|
let stated = r#"<style:style style:name="A" style:family="table-column"><style:table-column-properties style:column-width="2in" style:rel-column-width="2*"/></style:style><style:style style:name="B" style:family="table-column"><style:table-column-properties style:column-width="1in" style:rel-column-width="1*"/></style:style>"#;
|
||||||
|
let g = grid(&doc(stated, table));
|
||||||
|
assert!((g[0] / g[1] - 2.0).abs() < 0.01, "{g:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A share-only table wider than the text keeps its stated width, as in LibreOffice; a column with
|
||||||
|
/// only a width takes it first, and the shares divide the rest; a column with no share of its own
|
||||||
|
/// takes the default style's.
|
||||||
|
#[test]
|
||||||
|
fn columns_by_shares_divide_the_right_width() {
|
||||||
|
let column = |name: &str, props: &str| {
|
||||||
|
format!(r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties {props}/></style:style>"#)
|
||||||
|
};
|
||||||
|
let table_style = |props: &str| format!(r#"<style:style style:name="T" style:family="table"><style:table-properties {props}/></style:style>"#);
|
||||||
|
let table = |names: &[&str]| {
|
||||||
|
let columns: String = names
|
||||||
|
.iter()
|
||||||
|
.map(|n| if n.is_empty() { "<table:table-column/>".to_string() } else { format!(r#"<table:table-column table:style-name="{n}"/>"#) })
|
||||||
|
.collect();
|
||||||
|
let cells: String = names.iter().map(|_| "<table:table-cell><text:p>x</text:p></table:table-cell>").collect();
|
||||||
|
format!(r#"<table:table table:style-name="T">{columns}<table:table-row>{cells}</table:table-row></table:table>"#)
|
||||||
|
};
|
||||||
|
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
|
||||||
|
let near = |got: &[f32], want: &[f32]| {
|
||||||
|
assert!(got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.5), "{got:?}, not {want:?}")
|
||||||
|
};
|
||||||
|
// 576 pt, aligned, in a narrower text area.
|
||||||
|
let d = doc(
|
||||||
|
&format!(
|
||||||
|
"{}{}{}",
|
||||||
|
table_style(r#"style:width="8in" table:align="left""#),
|
||||||
|
column("A", r#"style:rel-column-width="1000*""#),
|
||||||
|
column("B", r#"style:rel-column-width="3000*""#)
|
||||||
|
),
|
||||||
|
&table(&["A", "B"]),
|
||||||
|
);
|
||||||
|
near(&grid(&d), &[144.0, 432.0]);
|
||||||
|
near(&grid(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), &[144.0, 432.0]);
|
||||||
|
// 72 pt stated, the other 300 by shares.
|
||||||
|
let d = doc(
|
||||||
|
&format!(
|
||||||
|
"{}{}{}{}",
|
||||||
|
table_style(r#"style:width="372pt" table:align="left""#),
|
||||||
|
column("A", r#"style:column-width="72pt""#),
|
||||||
|
column("B", r#"style:rel-column-width="1000*""#),
|
||||||
|
column("C", r#"style:rel-column-width="3000*""#)
|
||||||
|
),
|
||||||
|
&table(&["A", "B", "C"]),
|
||||||
|
);
|
||||||
|
near(&grid(&d), &[72.0, 75.0, 225.0]);
|
||||||
|
// A share on every column goes before a stated width, as in LibreOffice.
|
||||||
|
let d = doc(
|
||||||
|
&format!(
|
||||||
|
"{}{}{}",
|
||||||
|
table_style(r#"style:width="400pt" table:align="left""#),
|
||||||
|
column("A", r#"style:column-width="72pt" style:rel-column-width="1000*""#),
|
||||||
|
column("B", r#"style:rel-column-width="3000*""#)
|
||||||
|
),
|
||||||
|
&table(&["A", "B"]),
|
||||||
|
);
|
||||||
|
near(&grid(&d), &[100.0, 300.0]);
|
||||||
|
// An aligned table whose every column states both: the shares (measured in LibreOffice 26.8).
|
||||||
|
let d = doc(
|
||||||
|
&format!(
|
||||||
|
"{}{}{}",
|
||||||
|
table_style(r#"style:width="400pt" table:align="left""#),
|
||||||
|
column("A", r#"style:column-width="300pt" style:rel-column-width="1*""#),
|
||||||
|
column("B", r#"style:column-width="100pt" style:rel-column-width="1*""#)
|
||||||
|
),
|
||||||
|
&table(&["A", "B"]),
|
||||||
|
);
|
||||||
|
near(&grid(&d), &[200.0, 200.0]);
|
||||||
|
// A share too small for a column is the narrowest one, not the grid lost to the equal split.
|
||||||
|
let d = doc(
|
||||||
|
&format!(
|
||||||
|
"{}{}{}",
|
||||||
|
table_style(r#"style:width="400pt" table:align="left""#),
|
||||||
|
column("A", r#"style:rel-column-width="1*""#),
|
||||||
|
column("B", r#"style:rel-column-width="65535*""#)
|
||||||
|
),
|
||||||
|
&table(&["A", "B"]),
|
||||||
|
);
|
||||||
|
near(&grid(&d), &[6.0, 400.0]);
|
||||||
|
// The default style's share, for a column with none and one whose style says none.
|
||||||
|
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"><office:styles><style:default-style style:family="table-column"><style:table-column-properties style:rel-column-width="1000*"/></style:default-style></office:styles></office:document-styles>"#;
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}</office:automatic-styles><office:body><office:text>{}</office:text></office:body></office:document-content>"#,
|
||||||
|
table_style(r#"style:width="400pt" table:align="left""#),
|
||||||
|
column("B", r#"style:rel-column-width="3000*""#),
|
||||||
|
column("E", r#"style:use-optimal-column-width="false""#),
|
||||||
|
table(&["", "B"])
|
||||||
|
);
|
||||||
|
near(&grid(&import(&package(&content, Some(styles))).unwrap()), &[100.0, 300.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table inside a cell doesn't know its width from the page: with only shares and no width of
|
||||||
|
/// its own to divide, or with some columns stating widths, its columns split it equally, as before.
|
||||||
|
#[test]
|
||||||
|
fn a_table_in_a_cell_shares_out_no_page_width() {
|
||||||
|
let styles = r#"<style:style style:name="W" style:family="table-column"><style:table-column-properties style:column-width="100pt"/></style:style><style:style style:name="S" style:family="table-column"><style:table-column-properties style:rel-column-width="1*"/></style:style><style:style style:name="L" style:family="table"><style:table-properties table:align="left"/></style:style>"#;
|
||||||
|
for (style, columns) in [
|
||||||
|
(r#" table:style-name="L""#, r#"<table:table-column table:style-name="S"/><table:table-column table:style-name="S"/>"#),
|
||||||
|
("", r#"<table:table-column table:style-name="W"/><table:table-column table:style-name="S"/>"#),
|
||||||
|
] {
|
||||||
|
let inner = format!(
|
||||||
|
r#"<table:table{style}>{columns}<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#
|
||||||
|
);
|
||||||
|
let body = format!(
|
||||||
|
r#"<table:table><table:table-column table:style-name="W"/><table:table-column table:style-name="W"/><table:table-row><table:table-cell>{inner}</table:table-cell><table:table-cell><text:p>c</text:p></table:table-cell></table:table-row></table:table>"#
|
||||||
|
);
|
||||||
|
let d = doc(styles, &body);
|
||||||
|
let outer = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||||
|
let inner = outer.rows[0].cells[0].blocks.iter().find_map(|b| b.as_table()).unwrap();
|
||||||
|
// The equal split main gives, which layout fits to the cell (not two page-wide columns).
|
||||||
|
let equal = inner.grid.windows(2).all(|w| (w[0] - w[1]).abs() < 0.01);
|
||||||
|
assert!(equal && inner.grid.first().is_some_and(|w| *w < 300.0), "{style}: {:?}", inner.grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tables_and_rows_background_show_behind_their_cells() {
|
||||||
|
// Table Properties › Background: it was dropped, on screen and in LibreOffice after a save.
|
||||||
|
let styles = r##"<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ddffdd"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#ffdddd"/></style:style><style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#ddddff"/></style:style>"##;
|
||||||
|
let table = r#"<table:table table:style-name="T"><table:table-row><table:table-cell><text:p>table</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row><table:table-row table:style-name="R"><table:table-cell><text:p>row</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||||
|
let colours = |d: &wordcraft_doc::Document| {
|
||||||
|
d.body
|
||||||
|
.iter()
|
||||||
|
.find_map(|b| b.as_table())
|
||||||
|
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let want = |t: &str, r: &str, c: &str| vec![Some(t.to_string()), Some(c.to_string()), Some(r.to_string()), Some(c.to_string())];
|
||||||
|
let d = doc(styles, table);
|
||||||
|
assert_eq!(colours(&d), want("ddffdd", "ffdddd", "ddddff"));
|
||||||
|
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want("ddffdd", "ffdddd", "ddddff"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As LibreOffice shows them: a cell's own style, else its column's default cell style (a row's
|
||||||
|
/// isn't applied), else the row's background, else the table's. A row or table clearing its
|
||||||
|
/// background (`transparent`) doesn't take its parent's; the table's shows behind that row.
|
||||||
|
#[test]
|
||||||
|
fn a_cells_background_comes_from_its_column_then_row_then_table() {
|
||||||
|
let cell = |n: &str, c: &str| {
|
||||||
|
format!(
|
||||||
|
r##"<style:style style:name="{n}" style:family="table-cell"><style:table-cell-properties fo:background-color="#{c}"/></style:style>"##
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let styles = format!(
|
||||||
|
r##"{}{}<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ffff00"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style><style:style style:name="Clear" style:family="table-row" style:parent-style-name="R"><style:table-row-properties fo:background-color="transparent"/></style:style>"##,
|
||||||
|
cell("InRow", "ff0000"),
|
||||||
|
cell("InColumn", "00ff00"),
|
||||||
|
);
|
||||||
|
let c = "<table:table-cell><text:p>x</text:p></table:table-cell>";
|
||||||
|
let table = format!(
|
||||||
|
r#"<table:table table:style-name="T"><table:table-column table:default-cell-style-name="InColumn"/><table:table-column/><table:table-row table:style-name="R" table:default-cell-style-name="InRow">{c}{c}</table:table-row><table:table-row>{c}{c}</table:table-row><table:table-row table:style-name="Clear">{c}{c}</table:table-row></table:table>"#
|
||||||
|
);
|
||||||
|
let colours = |d: &wordcraft_doc::Document| {
|
||||||
|
d.body
|
||||||
|
.iter()
|
||||||
|
.find_map(|b| b.as_table())
|
||||||
|
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let s = |v: &str| Some(v.to_string());
|
||||||
|
let want = vec![s("00ff00"), s("0000ff"), s("00ff00"), s("ffff00"), s("00ff00"), s("ffff00")];
|
||||||
|
let d = doc(&styles, &table);
|
||||||
|
assert_eq!(colours(&d), want);
|
||||||
|
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A column default after a spanning cell belongs to the column the cell is in.
|
||||||
|
#[test]
|
||||||
|
fn a_column_default_counts_spanned_columns() {
|
||||||
|
let styles =
|
||||||
|
r##"<style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#00ff00"/></style:style>"##;
|
||||||
|
let table = r#"<table:table><table:table-column/><table:table-column/><table:table-column table:default-cell-style-name="C"/><table:table-row><table:table-cell table:number-columns-spanned="2"><text:p>a</text:p></table:table-cell><table:covered-table-cell/><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
|
||||||
|
let d = doc(styles, table);
|
||||||
|
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||||
|
let last = t.rows[0].cells.iter().rev().find(|c| !c.blocks.is_empty()).unwrap();
|
||||||
|
assert_eq!(last.props.shading.map(|s| s.hex().to_ascii_lowercase()).as_deref(), Some("00ff00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A column's default cell style that names nothing there is no background, however long its name.
|
||||||
|
#[test]
|
||||||
|
fn a_column_default_naming_no_style_is_no_background() {
|
||||||
|
let long = "x".repeat(100_000);
|
||||||
|
let table = format!(
|
||||||
|
r#"<table:table><table:table-column table:number-columns-repeated="63" table:default-cell-style-name="{long}"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
|
||||||
|
);
|
||||||
|
let d = doc("", &table);
|
||||||
|
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||||
|
assert_eq!(t.rows[0].cells[0].props.shading, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell style saying `transparent` over a coloured parent has no background of its own: what's
|
||||||
|
/// behind it shows, in LibreOffice too, from a column's default cell style or the cell's own.
|
||||||
|
#[test]
|
||||||
|
fn a_transparent_cell_style_stops_its_parents_colour() {
|
||||||
|
let styles = r##"<style:style style:name="PC" style:family="table-cell"><style:table-cell-properties fo:background-color="#ff0000"/></style:style><style:style style:name="KC" style:family="table-cell" style:parent-style-name="PC"><style:table-cell-properties fo:background-color="transparent"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style>"##;
|
||||||
|
for (column, cell) in [(r#" table:default-cell-style-name="KC""#, ""), ("", r#" table:style-name="KC""#)] {
|
||||||
|
let table = format!(
|
||||||
|
r#"<table:table><table:table-column{column}/><table:table-row table:style-name="R"><table:table-cell{cell}><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
|
||||||
|
);
|
||||||
|
let d = doc(styles, &table);
|
||||||
|
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||||
|
assert_eq!(t.rows[0].cells[0].props.shading, crate::model::parse_color("#0000ff"), "{column}{cell}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_row_that_keeps_together_says_so() {
|
fn a_row_that_keeps_together_says_so() {
|
||||||
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
|
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
|
||||||
|
|||||||
@@ -152,3 +152,23 @@ fn a_diagram_is_saved_as_a_group_of_its_drawing() {
|
|||||||
assert!(kinds.contains(&ShapeKind::RoundedRectangle) && kinds.contains(&ShapeKind::Arrow), "{kinds:?}");
|
assert!(kinds.contains(&ShapeKind::RoundedRectangle) && kinds.contains(&ShapeKind::Arrow), "{kinds:?}");
|
||||||
assert!(members.iter().any(|m| m.4 == "Plan"), "its text: {members:?}");
|
assert!(members.iter().any(|m| m.4 == "Plan"), "its text: {members:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A group anchored to a page stays on that page, and a number on another anchor says nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_group_anchored_to_a_page_stays_on_that_page() {
|
||||||
|
let saved = |attrs: &str| {
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:g {attrs}><draw:rect svg:x="3cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/><draw:rect svg:x="6cm" svg:y="3cm" svg:width="2cm" svg:height="2cm"/></draw:g><text:p>One</text:p></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let d = import(&package(&content, None)).unwrap();
|
||||||
|
crate::odt::tests::export_model(&d).unwrap()
|
||||||
|
};
|
||||||
|
let on_two = saved(r#"text:anchor-type="page" text:anchor-page-number="2""#);
|
||||||
|
let c = content_of(&on_two);
|
||||||
|
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="2""#), "{c}");
|
||||||
|
let back = import(&on_two).unwrap();
|
||||||
|
let again = content_of(&crate::odt::tests::export_model(&back).unwrap());
|
||||||
|
assert!(again.contains(r#"text:anchor-page-number="2""#), "again: {again}");
|
||||||
|
let c = content_of(&saved(r#"text:anchor-type="paragraph" text:anchor-page-number="2""#));
|
||||||
|
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{c}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,6 +168,38 @@ fn an_object_in_a_header_is_kept_too() {
|
|||||||
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
|
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A frame's stated size goes before fitting a column: on either side alone the other follows the
|
||||||
|
/// picture's proportions, and only past what a document holds does it shrink, in proportion.
|
||||||
|
#[test]
|
||||||
|
fn a_picture_keeps_the_size_its_frame_states_on_either_side() {
|
||||||
|
let png = |w: u32, h: u32| {
|
||||||
|
let mut out = std::io::Cursor::new(Vec::new());
|
||||||
|
image::RgbImage::new(w, h).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||||
|
out.into_inner()
|
||||||
|
};
|
||||||
|
let size = |w: Option<f32>, h: Option<f32>| {
|
||||||
|
let img = crate::odt::body::drawing::picture(png(200, 100), w, h, "").unwrap();
|
||||||
|
(img.w, img.h)
|
||||||
|
};
|
||||||
|
let near = |got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01, "{got:?}, not {want:?}");
|
||||||
|
// Both, one or the other, past a column's 468 pt.
|
||||||
|
near(size(Some(600.0), Some(100.0)), (600.0, 100.0));
|
||||||
|
near(size(Some(600.0), None), (600.0, 300.0));
|
||||||
|
near(size(None, Some(400.0)), (800.0, 400.0));
|
||||||
|
// Past what a document holds: in proportion, whichever side is stated.
|
||||||
|
near(size(Some(2000.0), Some(1000.0)), (1584.0, 792.0));
|
||||||
|
near(size(Some(2000.0), None), (1584.0, 792.0));
|
||||||
|
near(size(None, Some(1000.0)), (1584.0, 792.0));
|
||||||
|
near(size(Some(f32::MAX), None), (1584.0, 792.0));
|
||||||
|
// None stated, or nothing a size could be: its own, fitted to a column.
|
||||||
|
near(size(None, None), (150.0, 75.0));
|
||||||
|
for junk in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.0, -5.0] {
|
||||||
|
near(size(Some(junk), Some(junk)), (150.0, 75.0));
|
||||||
|
}
|
||||||
|
let wide = crate::odt::body::drawing::picture(png(1000, 500), None, None, "").unwrap();
|
||||||
|
near((wide.w, wide.h), (468.0, 234.0));
|
||||||
|
}
|
||||||
|
|
||||||
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
|
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
|
||||||
/// can't show it, and a save from the model writes it back.
|
/// can't show it, and a save from the model writes it back.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -112,3 +112,327 @@ fn a_heading_looks_as_its_style_and_has_the_level_its_element_gives() {
|
|||||||
let back = import(&saved).unwrap();
|
let back = import(&saved).unwrap();
|
||||||
assert_eq!((props(&back, 0).style.as_deref(), props(&back, 0).outline_level), (Some("Heading2"), Some(0)));
|
assert_eq!((props(&back, 0).style.as_deref(), props(&back, 0).outline_level), (Some("Heading2"), Some(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A document with these common `styles` and this `body`.
|
||||||
|
fn in_custom_style(styles: &str, body: &str) -> Document {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph"/>{styles}</office:styles></office:document-styles>"#
|
||||||
|
);
|
||||||
|
let content =
|
||||||
|
format!(r#"<office:document-content {NS_T}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#);
|
||||||
|
import(&package(&content, Some(&styles))).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEAD: &str = r#"<style:style style:name="Head" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1" style:class="text"><style:text-properties fo:font-size="26pt" fo:font-weight="bold"/></style:style>"#;
|
||||||
|
|
||||||
|
fn saved_after_an_edit(d: &mut Document) -> Vec<u8> {
|
||||||
|
d.insert_text(&wordcraft_doc::Pos::body(0, 0), "Yearly ", &Default::default()).unwrap();
|
||||||
|
export(d).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_custom_style_with_an_outline_level_stays_the_paragraphs_style() {
|
||||||
|
// The outline level is the style's; the look stays the file's own, not Heading 1's.
|
||||||
|
let mut d = in_custom_style(HEAD, r#"<text:p text:style-name="Head">Annual report edition</text:p>"#);
|
||||||
|
let p = d.body[0].as_para().unwrap().props.clone();
|
||||||
|
assert_eq!(p.style.as_deref(), Some("Head"));
|
||||||
|
assert_eq!(d.styles.resolve_para(&p).outline_level, Some(0), "still a heading in WordCraft");
|
||||||
|
let look = d.styles.resolve_char(p.style.as_deref(), &Default::default());
|
||||||
|
assert_eq!((look.size, look.bold), (26.0, true));
|
||||||
|
let r = d.body[0].as_para().unwrap().runs.first().unwrap().props.clone();
|
||||||
|
assert!(r.size.is_none() && r.bold.is_none(), "the look stays in the style: {r:?}");
|
||||||
|
|
||||||
|
// Edited, it is written from the model, under its own name, at its level.
|
||||||
|
let saved = saved_after_an_edit(&mut d);
|
||||||
|
let c = crate::odt::tests::content_of(&saved);
|
||||||
|
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">Yearly Annual"#), "{c}");
|
||||||
|
let s = crate::odt::tests::styles_of(&saved);
|
||||||
|
let head = s.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).unwrap_or("");
|
||||||
|
assert!(head.contains(r#"style:default-outline-level="1""#) && head.contains(r#"fo:font-size="26pt""#), "{head}");
|
||||||
|
|
||||||
|
// And read back, it is the same heading.
|
||||||
|
let back = import(&saved).unwrap();
|
||||||
|
let p = back.body[0].as_para().unwrap().props.clone();
|
||||||
|
assert_eq!((p.style.as_deref(), back.styles.resolve_para(&p).outline_level), (Some("Head"), Some(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unedited_paragraph_in_such_a_style_is_written_as_read() {
|
||||||
|
let body = r#"<text:p text:style-name="Head">Annual report edition</text:p>"#;
|
||||||
|
let saved = export(&in_custom_style(HEAD, body)).unwrap();
|
||||||
|
assert!(crate::odt::tests::content_of(&saved).contains(body));
|
||||||
|
let s = crate::odt::tests::styles_of(&saved);
|
||||||
|
assert_eq!(s.matches(r#"style:name="Head""#).count(), 1, "{s}");
|
||||||
|
assert!(s.contains(r#"style:default-outline-level="1""#), "{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_level_of_such_a_style_is_written_as_it_is() {
|
||||||
|
// The paragraph kinds stop at Heading 6; the outline levels go on, and a style based on another
|
||||||
|
// heading has its own.
|
||||||
|
let styles = r#"<style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Deep" style:family="paragraph" style:default-outline-level="8"/><style:style style:name="Under" style:family="paragraph" style:parent-style-name="Heading_20_2" style:default-outline-level="3"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Deep"/>"#;
|
||||||
|
let body =
|
||||||
|
r#"<text:p text:style-name="Deep">deep</text:p><text:p text:style-name="Under">under</text:p><text:p text:style-name="Child">child</text:p>"#;
|
||||||
|
let d = in_custom_style(styles, body);
|
||||||
|
let level = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).outline_level;
|
||||||
|
assert_eq!([level(&d, 0), level(&d, 1), level(&d, 2)], [Some(7), Some(2), Some(7)]);
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let c = crate::odt::tests::content_of(&saved);
|
||||||
|
assert!(c.contains(r#"<text:h text:style-name="Deep" text:outline-level="8">deep"#), "{c}");
|
||||||
|
assert!(c.contains(r#"<text:h text:style-name="Under" text:outline-level="3">under"#), "{c}");
|
||||||
|
assert!(c.contains(r#"<text:h text:style-name="Child" text:outline-level="8">child"#), "{c}");
|
||||||
|
let back = import(&saved).unwrap();
|
||||||
|
assert_eq!([level(&back, 0), level(&back, 1), level(&back, 2)], [Some(7), Some(2), Some(7)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_paragraphs_own_level_and_formatting_stay_over_such_a_style() {
|
||||||
|
let styles = format!(r#"<office:document-styles {NS_T}><office:styles>{HEAD}</office:styles></office:document-styles>"#);
|
||||||
|
let content = format!(
|
||||||
|
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="P1" style:family="paragraph" style:parent-style-name="Head"><style:paragraph-properties fo:text-align="center"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="P1" text:outline-level="2">Moved</text:h></office:text></office:body></office:document-content>"#
|
||||||
|
);
|
||||||
|
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||||
|
let p = d.body[0].as_para().unwrap().props.clone();
|
||||||
|
assert_eq!((p.style.as_deref(), p.outline_level, p.align), (Some("Head"), Some(1), Some(wordcraft_doc::Align::Center)));
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert!(c.contains(r#"text:outline-level="2">Moved"#), "{c}");
|
||||||
|
assert!(c.contains(r#"style:parent-style-name="Head""#) && c.contains(r#"fo:text-align="center""#), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hostile_outline_level_on_a_custom_style_is_no_heading() {
|
||||||
|
for bad in ["0", "11", "255", "999", "-1", "x", ""] {
|
||||||
|
let styles = format!(r#"<style:style style:name="Head" style:family="paragraph" style:default-outline-level="{bad}"/>"#);
|
||||||
|
let d = in_custom_style(&styles, r#"<text:p text:style-name="Head">Plain</text:p>"#);
|
||||||
|
let p = d.body[0].as_para().unwrap().props.clone();
|
||||||
|
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Head"), None), "{bad}");
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert!(c.contains(r#"<text:p text:style-name="Head">Plain"#), "{bad}: {c}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn such_a_style_is_numbered_with_its_level() {
|
||||||
|
// LibreOffice numbers it through the outline style, one list with the level's headings.
|
||||||
|
let d = in_custom_style(
|
||||||
|
&format!(
|
||||||
|
r#"{HEAD}<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{NUMBERED}"#
|
||||||
|
),
|
||||||
|
r#"<text:p text:style-name="Head">First</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Second</text:h>"#,
|
||||||
|
);
|
||||||
|
let num = |d: &Document, i: usize| d.styles.resolve_para(&d.body[i].as_para().unwrap().props).numbering;
|
||||||
|
assert!(num(&d, 0).is_some());
|
||||||
|
assert_eq!(num(&d, 0), num(&d, 1));
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let c = crate::odt::tests::content_of(&saved);
|
||||||
|
assert!(!c.contains("<text:list"), "the outline numbers it: {c}");
|
||||||
|
assert!(c.contains(r#"<text:h text:style-name="Head" text:outline-level="1">First"#), "{c}");
|
||||||
|
// A numbered level stays attached to the outline; an unnumbered one doesn't take its indent.
|
||||||
|
let head = |xml: String| xml.split("<style:style ").find(|x| x.starts_with(r#"style:name="Head""#)).map(String::from).unwrap_or_default();
|
||||||
|
assert!(!head(crate::odt::tests::styles_of(&saved)).contains("style:list-style-name"));
|
||||||
|
let back = import(&saved).unwrap();
|
||||||
|
assert!(num(&back, 0).is_some());
|
||||||
|
assert_eq!(num(&back, 0), num(&back, 1));
|
||||||
|
let plain = in_custom_style(HEAD, r#"<text:p text:style-name="Head">First</text:p>"#);
|
||||||
|
let s = crate::odt::tests::styles_of(&crate::odt::tests::export_model(&plain).unwrap());
|
||||||
|
assert!(head(s).contains(r#"style:list-style-name="""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_styles_based_on_any_kinds_style_keep_their_name_when_edited() {
|
||||||
|
// The kind's style took their place, without their own look.
|
||||||
|
let styles = r##"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Quotations" style:family="paragraph"/><style:style style:name="Preformatted_20_Text" style:display-name="Preformatted Text" style:family="paragraph"/><style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title"><style:text-properties fo:color="#cc6600"/></style:style><style:style style:name="Aside" style:family="paragraph" style:parent-style-name="Quotations"/><style:style style:name="Listing" style:family="paragraph" style:parent-style-name="Preformatted_20_Text"/><style:style style:name="Plaque" style:family="paragraph" style:parent-style-name="Heading_20_2"/>"##;
|
||||||
|
let body = r#"<text:p text:style-name="Banner">b</text:p><text:p text:style-name="Aside">a</text:p><text:p text:style-name="Listing">l</text:p><text:p text:style-name="Plaque">p</text:p>"#;
|
||||||
|
let saved = crate::odt::tests::export_model(&in_custom_style(styles, body)).unwrap();
|
||||||
|
let c = crate::odt::tests::content_of(&saved);
|
||||||
|
for want in [
|
||||||
|
r#"<text:p text:style-name="Banner">b"#,
|
||||||
|
r#"<text:p text:style-name="Aside">a"#,
|
||||||
|
r#"<text:p text:style-name="Listing">l"#,
|
||||||
|
r#"<text:h text:style-name="Plaque" text:outline-level="2">p"#,
|
||||||
|
] {
|
||||||
|
assert!(c.contains(want), "{want} in {c}");
|
||||||
|
}
|
||||||
|
let s = crate::odt::tests::styles_of(&saved);
|
||||||
|
assert!(s.contains(r#"style:name="Banner" style:display-name="Banner" style:family="paragraph" style:parent-style-name="Title""#), "{s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading's number is its own outline level's, as in LibreOffice, not its style's: `(level)` of
|
||||||
|
/// each paragraph's number, as layout takes it.
|
||||||
|
fn number_levels(d: &Document) -> Vec<Option<u8>> {
|
||||||
|
d.body
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| b.as_para())
|
||||||
|
.map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0).map(|n| n.level))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_headings_own_outline_level_gives_its_number() {
|
||||||
|
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format="1" text:display-levels="2" style:num-suffix="."/><text:outline-level-style text:level="3" style:num-format=""/></text:outline-style>"#;
|
||||||
|
let styles = format!(
|
||||||
|
r#"{HEAD}<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"/>{outline}"#
|
||||||
|
);
|
||||||
|
let h = |style: &str, level: u8| format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}</text:h>"#);
|
||||||
|
let body = [h("Head", 1), h("Head", 2), h("Head", 3), h("Low", 3), h("Low", 1), h("Low", 2), h("Heading_20_1", 2), h("Heading_20_1", 3)].concat();
|
||||||
|
let d = in_custom_style(&styles, &body);
|
||||||
|
let want = vec![Some(0), Some(1), None, None, Some(0), Some(1), Some(1), None];
|
||||||
|
assert_eq!(number_levels(&d), want);
|
||||||
|
let nums: std::collections::BTreeSet<u32> = d
|
||||||
|
.body
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| b.as_para())
|
||||||
|
.filter_map(|p| p.props.numbering.or(d.styles.resolve_para(&p.props).numbering))
|
||||||
|
.map(|n| n.num)
|
||||||
|
.filter(|n| *n != 0)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(nums.len(), 1, "one list with the headings': {nums:?}");
|
||||||
|
// Written from the model the outline still numbers them, by the levels stated, and read back
|
||||||
|
// they are as they were.
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let c = crate::odt::tests::content_of(&saved);
|
||||||
|
assert!(!c.contains("<text:list"), "{c}");
|
||||||
|
for (style, level) in [("Head", 2), ("Low", 1), ("Heading_20_1", 3)] {
|
||||||
|
assert!(c.contains(&format!(r#"<text:h text:style-name="{style}" text:outline-level="{level}">{style} {level}"#)), "{c}");
|
||||||
|
}
|
||||||
|
assert_eq!(number_levels(&import(&saved).unwrap()), want);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading in a list is that list's, whatever its outline level would number.
|
||||||
|
#[test]
|
||||||
|
fn a_heading_in_a_list_keeps_the_lists_number() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"{HEAD}{NUMBERED}<text:list-style style:name="L"><text:list-level-style-number text:level="1" style:num-format="a"/></text:list-style>"#
|
||||||
|
);
|
||||||
|
let d = in_custom_style(
|
||||||
|
&styles,
|
||||||
|
r#"<text:list text:style-name="L"><text:list-item><text:h text:style-name="Head" text:outline-level="2">x</text:h></text:list-item></text:list>"#,
|
||||||
|
);
|
||||||
|
let p = d.body[0].as_para().unwrap();
|
||||||
|
let n = p.props.numbering.expect("its list");
|
||||||
|
assert_eq!(d.numbering.level(n.num, n.level).map(|l| l.format), Some(NumFormat::LowerLetter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A style based on Title with an outline level is a heading at that level, in its own name.
|
||||||
|
#[test]
|
||||||
|
fn a_title_based_style_with_an_outline_level_stays_a_heading() {
|
||||||
|
let styles = r#"<style:style style:name="Title" style:family="paragraph"/><style:style style:name="Banner" style:family="paragraph" style:parent-style-name="Title" style:default-outline-level="1"/>"#;
|
||||||
|
let mut d = in_custom_style(styles, r#"<text:p text:style-name="Banner">report</text:p>"#);
|
||||||
|
let p = d.body[0].as_para().unwrap().props.clone();
|
||||||
|
assert_eq!((p.style.as_deref(), d.styles.resolve_para(&p).outline_level), (Some("Banner"), Some(0)));
|
||||||
|
let saved = saved_after_an_edit(&mut d);
|
||||||
|
let c = crate::odt::tests::content_of(&saved);
|
||||||
|
assert!(c.contains(r#"<text:h text:style-name="Banner" text:outline-level="1">Yearly report"#), "{c}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A style based on a numbered heading, at a level the outline doesn't number, has no number: as
|
||||||
|
/// in LibreOffice, not its parent's.
|
||||||
|
#[test]
|
||||||
|
fn a_style_at_an_unnumbered_level_has_no_number_from_its_parent() {
|
||||||
|
let outline = r#"<text:outline-style style:name="Outline"><text:outline-level-style text:level="1" style:num-format="1" style:num-suffix="."/><text:outline-level-style text:level="2" style:num-format=""/><text:outline-level-style text:level="3" style:num-format="1" style:num-suffix="."/></text:outline-style>"#;
|
||||||
|
let styles = format!(
|
||||||
|
r#"<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="1"/><style:style style:name="Child" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="2"/><style:style style:name="Grand" style:family="paragraph" style:parent-style-name="Heading_20_1" style:default-outline-level="3"/>{outline}"#
|
||||||
|
);
|
||||||
|
let body = r#"<text:h text:style-name="Heading_20_1" text:outline-level="1">One</text:h><text:p text:style-name="Child">As p</text:p><text:h text:style-name="Child" text:outline-level="2">As h</text:h><text:p text:style-name="Grand">Third</text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">Two</text:h>"#;
|
||||||
|
let d = in_custom_style(&styles, body);
|
||||||
|
let want = vec![Some(0), None, None, Some(2), Some(0)];
|
||||||
|
assert_eq!(number_levels(&d), want);
|
||||||
|
let saved = crate::odt::tests::export_model(&d).unwrap();
|
||||||
|
let written = crate::odt::tests::styles_of(&saved);
|
||||||
|
let child = written.split("<style:style ").find(|x| x.starts_with(r#"style:name="Child""#)).unwrap_or_default();
|
||||||
|
assert!(child.contains(r#"style:list-style-name="""#), "LibreOffice gives it no number: {child}");
|
||||||
|
assert_eq!(number_levels(&import(&saved).unwrap()), want);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading in a list of its own stays in it, whatever the list's levels are linked to: only the
|
||||||
|
/// number the outline itself gives a heading goes with the outline.
|
||||||
|
#[test]
|
||||||
|
fn a_heading_in_a_list_linked_to_a_style_stays_in_it() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
use wordcraft_doc::props::NumRef;
|
||||||
|
let mut d = Document::new();
|
||||||
|
let numbering = Arc::make_mut(&mut d.numbering);
|
||||||
|
let num = numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||||
|
let first = numbering.abstracts.last_mut().and_then(|a| a.levels.first_mut()).unwrap();
|
||||||
|
first.style = Some("Heading1".into());
|
||||||
|
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
|
||||||
|
p.props.style = Some("Heading2".into());
|
||||||
|
p.props.numbering = Some(NumRef { num, level: 1 });
|
||||||
|
let has_list = |d: &Document| crate::odt::tests::content_of(&crate::odt::tests::export_model(d).unwrap()).contains("<text:list");
|
||||||
|
// At a deeper level than the link, no level of its own stated.
|
||||||
|
d.body = vec![wordcraft_doc::para_block(p.clone())];
|
||||||
|
assert!(has_list(&d), "a list of its own");
|
||||||
|
// At its own level too, but its heading style isn't numbered: the outline wouldn't number it.
|
||||||
|
p.props.outline_level = Some(1);
|
||||||
|
d.body = vec![wordcraft_doc::para_block(p)];
|
||||||
|
assert!(has_list(&d), "the outline has no number for it");
|
||||||
|
// Its heading style numbered there by this list: the outline's number.
|
||||||
|
Arc::make_mut(&mut d.styles).get_mut("Heading2").unwrap().para.numbering = Some(NumRef { num, level: 1 });
|
||||||
|
assert!(!has_list(&d), "the outline's");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Past Heading 6 the outline written has no number to give, so a heading there keeps its list.
|
||||||
|
#[test]
|
||||||
|
fn a_heading_past_level_six_keeps_its_list() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
use wordcraft_doc::props::NumRef;
|
||||||
|
let mut d = Document::new();
|
||||||
|
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||||
|
Arc::make_mut(&mut d.styles).get_mut("Heading1").unwrap().para.numbering = Some(NumRef { num, level: 0 });
|
||||||
|
for level in 6..=8 {
|
||||||
|
let mut p = wordcraft_doc::Paragraph::with_text("x", Default::default());
|
||||||
|
p.props.style = Some("Heading6".into());
|
||||||
|
p.props.outline_level = Some(level);
|
||||||
|
p.props.numbering = Some(NumRef { num, level });
|
||||||
|
d.body = vec![wordcraft_doc::para_block(p)];
|
||||||
|
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
|
||||||
|
assert!(c.contains("<text:list"), "level {level}: {c}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading past level six numbered through its style keeps its list too, as the outline written
|
||||||
|
/// numbers only six: read, written from the model and with its style edited.
|
||||||
|
#[test]
|
||||||
|
fn a_heading_past_level_six_numbered_by_its_style_keeps_its_list() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
for level in 7..=9u8 {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<style:style style:name="Deep" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="{level}"/><text:outline-style style:name="Outline"><text:outline-level-style text:level="{level}" style:num-format="1" style:num-suffix="."/></text:outline-style>"#
|
||||||
|
);
|
||||||
|
let body = format!(r#"<text:h text:style-name="Deep" text:outline-level="{level}">x</text:h>"#);
|
||||||
|
let mut d = in_custom_style(&styles, &body);
|
||||||
|
let check = |saved: &[u8], what: &str| {
|
||||||
|
let c = crate::odt::tests::content_of(saved);
|
||||||
|
assert!(c.contains("<text:list"), "level {level}, {what}: {c}");
|
||||||
|
};
|
||||||
|
check(&crate::odt::tests::export_model(&d).unwrap(), "from the model");
|
||||||
|
let id = d.body[0].as_para().unwrap().props.style.clone().unwrap();
|
||||||
|
Arc::make_mut(&mut d.styles).get_mut(&id).unwrap().chr.italic = Some(true);
|
||||||
|
let saved = export(&d).unwrap();
|
||||||
|
check(&saved, "style edited");
|
||||||
|
let back = import(&saved).unwrap();
|
||||||
|
let p = back.body[0].as_para().unwrap();
|
||||||
|
let n = p.props.numbering.or_else(|| back.styles.para_style_props(p.props.style.as_deref().unwrap_or("Normal")).0.numbering);
|
||||||
|
assert_eq!(n.map(|n| n.level), Some(level - 1), "level {level}, reopened");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A style at an unnumbered level with a heading numbered by its own level stays on the outline,
|
||||||
|
/// or LibreOffice shows that heading no number; with none, it comes off, for the level's indent.
|
||||||
|
#[test]
|
||||||
|
fn a_style_with_a_heading_numbered_by_its_own_level_stays_on_the_outline() {
|
||||||
|
let styles = format!(
|
||||||
|
r#"<style:style style:name="Low" style:family="paragraph" style:parent-style-name="Standard" style:default-outline-level="3"/><style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph" style:default-outline-level="3"/>{NUMBERED}"#
|
||||||
|
);
|
||||||
|
let saved = |body: &str| crate::odt::tests::styles_of(&crate::odt::tests::export_model(&in_custom_style(&styles, body)).unwrap());
|
||||||
|
let style_of = |xml: &str, name: &str| {
|
||||||
|
xml.split("<style:style ").find(|x| x.starts_with(&format!(r#"style:name="{name}""#))).map(String::from).unwrap_or_default()
|
||||||
|
};
|
||||||
|
let xml = saved(
|
||||||
|
r#"<text:h text:style-name="Low" text:outline-level="1">a</text:h><text:h text:style-name="Heading_20_3" text:outline-level="2">b</text:h>"#,
|
||||||
|
);
|
||||||
|
assert!(!style_of(&xml, "Low").contains("style:list-style-name"), "{xml}");
|
||||||
|
assert!(!style_of(&xml, "Heading_20_3").contains("style:list-style-name"), "{xml}");
|
||||||
|
let xml = saved(r#"<text:h text:style-name="Low" text:outline-level="3">a</text:h>"#);
|
||||||
|
assert!(style_of(&xml, "Low").contains(r#"style:list-style-name="""#), "{xml}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ use indexes::index_at;
|
|||||||
pub(super) use indexes::{IndexKind, index_instr};
|
pub(super) use indexes::{IndexKind, index_instr};
|
||||||
use notes::annotation_name;
|
use notes::annotation_name;
|
||||||
pub(super) use props::{
|
pub(super) use props::{
|
||||||
align_attr, border_attrs, generated_style, generating_style, odf_border, odf_name, props_text, script_attrs, spacing_props, tab_stops,
|
align_attr, border_attrs, generated_style, generating_style, is_script, lang_attrs, odf_border, odf_name, props_text, script_attrs,
|
||||||
|
spacing_props, tab_stops,
|
||||||
};
|
};
|
||||||
use props::{parent_style, text_props};
|
use props::{parent_style, text_props};
|
||||||
use tracked::{Item, join_deleted_marks};
|
use tracked::{Item, join_deleted_marks};
|
||||||
@@ -321,7 +322,7 @@ impl Writer {
|
|||||||
/// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3
|
/// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3
|
||||||
/// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322).
|
/// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322).
|
||||||
fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String {
|
fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String {
|
||||||
let named = p.extra.as_ref().and_then(|x| x.style.as_deref()).filter(|_| matches!(p.kind, Kind::Normal | Kind::Rule));
|
let named = p.extra.as_ref().and_then(|x| x.style.as_deref());
|
||||||
let parent = match (p.kind, self.note_parent, named) {
|
let parent = match (p.kind, self.note_parent, named) {
|
||||||
// A custom style: written as a common style of the same name (see `custom_styles`).
|
// A custom style: written as a common style of the same name (see `custom_styles`).
|
||||||
(_, _, Some(id)) => {
|
(_, _, Some(id)) => {
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ pub(in crate::odt) fn placement(place: Place<'_>, w: f32, h: f32) -> (String, St
|
|||||||
match place {
|
match place {
|
||||||
Place::Text(Some(f)) if f.wrap != wordcraft_doc::para::Wrap::Inline => {
|
Place::Text(Some(f)) if f.wrap != wordcraft_doc::para::Wrap::Inline => {
|
||||||
let (a, props, xy) = crate::odt::frames::frame_of(f, w, h);
|
let (a, props, xy) = crate::odt::frames::frame_of(f, w, h);
|
||||||
(anchor(a), props, xy)
|
let page = if a == "page" && f.page > 0 { format!(" text:anchor-page-number=\"{}\"", f.page) } else { String::new() };
|
||||||
|
(format!("{}{page}", anchor(a)), props, xy)
|
||||||
}
|
}
|
||||||
Place::Text(f) => {
|
Place::Text(f) => {
|
||||||
let turn = f.filter(|f| crate::odt::frames::turned(f)).map(|f| crate::odt::frames::transform_attr(0.0, 0.0, w, h, f.rot));
|
let turn = f.filter(|f| crate::odt::frames::turned(f)).map(|f| crate::odt::frames::transform_attr(0.0, 0.0, w, h, f.rot));
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
|
|||||||
let on = |v: Option<bool>| v == Some(true);
|
let on = |v: Option<bool>| v == Some(true);
|
||||||
if on(x.caps) {
|
if on(x.caps) {
|
||||||
p.push_str(" fo:text-transform=\"uppercase\"");
|
p.push_str(" fo:text-transform=\"uppercase\"");
|
||||||
|
} else if x.caps == Some(false) {
|
||||||
|
p.push_str(" fo:text-transform=\"none\"");
|
||||||
}
|
}
|
||||||
if on(x.small_caps) {
|
if on(x.small_caps) {
|
||||||
p.push_str(" fo:font-variant=\"small-caps\"");
|
p.push_str(" fo:font-variant=\"small-caps\"");
|
||||||
|
} else if x.small_caps == Some(false) {
|
||||||
|
p.push_str(" fo:font-variant=\"normal\"");
|
||||||
}
|
}
|
||||||
if on(x.hidden) {
|
if on(x.hidden) {
|
||||||
p.push_str(" text:display=\"none\"");
|
p.push_str(" text:display=\"none\"");
|
||||||
@@ -54,14 +58,7 @@ pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Opt
|
|||||||
}
|
}
|
||||||
p.push_str(&script_attrs(x));
|
p.push_str(&script_attrs(x));
|
||||||
if let Some(l) = x.lang.as_deref() {
|
if let Some(l) = x.lang.as_deref() {
|
||||||
let mut parts = l.split(['-', '_']);
|
p.push_str(&lang_attrs(l, ""));
|
||||||
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
|
|
||||||
if let Some(lang) = parts.next().filter(ok) {
|
|
||||||
p.push_str(&format!(" fo:language=\"{lang}\""));
|
|
||||||
if let Some(country) = parts.next().filter(ok) {
|
|
||||||
p.push_str(&format!(" fo:country=\"{country}\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
@@ -85,28 +82,7 @@ pub(in crate::odt) fn props_text(cp: &wordcraft_doc::props::CharProps) -> String
|
|||||||
extra: Some(Box::new(cp.clone())),
|
extra: Some(Box::new(cp.clone())),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut p = text_props(&f);
|
text_props(&f)
|
||||||
// What a style turns off that the style it is based on may turn on: said so, or the parent's
|
|
||||||
// bold, italic, underline or strike-through would show through.
|
|
||||||
if cp.bold == Some(false) {
|
|
||||||
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
|
|
||||||
if cp.bold_cs.is_none() {
|
|
||||||
p.push_str(" style:font-weight-complex=\"normal\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cp.italic == Some(false) {
|
|
||||||
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
|
|
||||||
if cp.italic_cs.is_none() {
|
|
||||||
p.push_str(" style:font-style-complex=\"normal\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cp.underline == Some(Underline::None) {
|
|
||||||
p.push_str(" style:text-underline-style=\"none\"");
|
|
||||||
}
|
|
||||||
if cp.strike == Some(false) && cp.double_strike != Some(true) {
|
|
||||||
p.push_str(" style:text-line-through-style=\"none\"");
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `fo:text-align` for `a` (ODF 1.3 §20.223).
|
/// `fo:text-align` for `a` (ODF 1.3 §20.223).
|
||||||
@@ -120,8 +96,8 @@ pub(in crate::odt) fn align_attr(a: Option<Align>) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `fo:language`/`fo:country` (with `suffix`: `-asian`, `-complex` on `style:`) for BCP 47 tag
|
/// `fo:language`, `fo:script` and `fo:country` (with `suffix`: `-asian`, `-complex` on `style:`)
|
||||||
/// `tag`.
|
/// for BCP 47 tag `tag`. A script is the four-letter subtag after the language.
|
||||||
pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
|
pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
|
||||||
let mut parts = tag.split(['-', '_']);
|
let mut parts = tag.split(['-', '_']);
|
||||||
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
|
let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric());
|
||||||
@@ -129,13 +105,23 @@ pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String {
|
|||||||
let mut p = String::new();
|
let mut p = String::new();
|
||||||
if let Some(lang) = parts.next().filter(ok) {
|
if let Some(lang) = parts.next().filter(ok) {
|
||||||
p.push_str(&format!(" {ns}:language{suffix}=\"{lang}\""));
|
p.push_str(&format!(" {ns}:language{suffix}=\"{lang}\""));
|
||||||
if let Some(country) = parts.next().filter(ok) {
|
let mut next = parts.next().filter(ok);
|
||||||
|
if let Some(script) = next.filter(|s| is_script(s)) {
|
||||||
|
p.push_str(&format!(" {ns}:script{suffix}=\"{script}\""));
|
||||||
|
next = parts.next().filter(ok);
|
||||||
|
}
|
||||||
|
if let Some(country) = next {
|
||||||
p.push_str(&format!(" {ns}:country{suffix}=\"{country}\""));
|
p.push_str(&format!(" {ns}:country{suffix}=\"{country}\""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a language tag's subtag is a script (BCP 47: four letters).
|
||||||
|
pub(in crate::odt) fn is_script(subtag: &str) -> bool {
|
||||||
|
subtag.len() == 4 && subtag.chars().all(|c| c.is_ascii_alphabetic())
|
||||||
|
}
|
||||||
|
|
||||||
/// The Asian and complex-script attributes of `x`: fonts, complex size, weight, posture and the
|
/// The Asian and complex-script attributes of `x`: fonts, complex size, weight, posture and the
|
||||||
/// two languages.
|
/// two languages.
|
||||||
pub(in crate::odt) fn script_attrs(x: &wordcraft_doc::props::CharProps) -> String {
|
pub(in crate::odt) fn script_attrs(x: &wordcraft_doc::props::CharProps) -> String {
|
||||||
@@ -168,19 +154,32 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
|||||||
let mut p = String::new();
|
let mut p = String::new();
|
||||||
// Complex-script weight and posture of their own are written with the other script extras.
|
// Complex-script weight and posture of their own are written with the other script extras.
|
||||||
let own_cs = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| f.extra.as_deref().and_then(pick).is_some();
|
let own_cs = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| f.extra.as_deref().and_then(pick).is_some();
|
||||||
|
let extra = f.extra.as_deref();
|
||||||
|
// What it turns off that its style, or the style that is based on, may turn on: said so, or
|
||||||
|
// that bold, italic, underline or strike-through would show through.
|
||||||
|
let off = |pick: fn(&wordcraft_doc::props::CharProps) -> Option<bool>| extra.and_then(pick) == Some(false);
|
||||||
if f.bold {
|
if f.bold {
|
||||||
p.push_str(" fo:font-weight=\"bold\" style:font-weight-asian=\"bold\"");
|
p.push_str(" fo:font-weight=\"bold\" style:font-weight-asian=\"bold\"");
|
||||||
if !own_cs(|x| x.bold_cs) {
|
if !own_cs(|x| x.bold_cs) {
|
||||||
p.push_str(" style:font-weight-complex=\"bold\"");
|
p.push_str(" style:font-weight-complex=\"bold\"");
|
||||||
}
|
}
|
||||||
|
} else if off(|x| x.bold) {
|
||||||
|
p.push_str(" fo:font-weight=\"normal\" style:font-weight-asian=\"normal\"");
|
||||||
|
if !own_cs(|x| x.bold_cs) {
|
||||||
|
p.push_str(" style:font-weight-complex=\"normal\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if f.italic {
|
if f.italic {
|
||||||
p.push_str(" fo:font-style=\"italic\" style:font-style-asian=\"italic\"");
|
p.push_str(" fo:font-style=\"italic\" style:font-style-asian=\"italic\"");
|
||||||
if !own_cs(|x| x.italic_cs) {
|
if !own_cs(|x| x.italic_cs) {
|
||||||
p.push_str(" style:font-style-complex=\"italic\"");
|
p.push_str(" style:font-style-complex=\"italic\"");
|
||||||
}
|
}
|
||||||
|
} else if off(|x| x.italic) {
|
||||||
|
p.push_str(" fo:font-style=\"normal\" style:font-style-asian=\"normal\"");
|
||||||
|
if !own_cs(|x| x.italic_cs) {
|
||||||
|
p.push_str(" style:font-style-complex=\"normal\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let extra = f.extra.as_deref();
|
|
||||||
if f.underline {
|
if f.underline {
|
||||||
use wordcraft_doc::props::Underline as U;
|
use wordcraft_doc::props::Underline as U;
|
||||||
let (style, kind, width, mode) = match extra.and_then(|e| e.underline) {
|
let (style, kind, width, mode) = match extra.and_then(|e| e.underline) {
|
||||||
@@ -201,11 +200,16 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
|||||||
" style:text-underline-style=\"{style}\" style:text-underline-type=\"{kind}\" style:text-underline-width=\"{width}\" style:text-underline-color=\"{color}\"{mode}"
|
" style:text-underline-style=\"{style}\" style:text-underline-type=\"{kind}\" style:text-underline-width=\"{width}\" style:text-underline-color=\"{color}\"{mode}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if !f.underline && extra.is_some_and(|e| e.underline == Some(wordcraft_doc::props::Underline::None)) {
|
||||||
|
p.push_str(" style:text-underline-style=\"none\"");
|
||||||
|
}
|
||||||
if f.strike || extra.is_some_and(|e| e.double_strike == Some(true)) {
|
if f.strike || extra.is_some_and(|e| e.double_strike == Some(true)) {
|
||||||
p.push_str(" style:text-line-through-style=\"solid\"");
|
p.push_str(" style:text-line-through-style=\"solid\"");
|
||||||
if extra.is_some_and(|e| e.double_strike == Some(true)) {
|
if extra.is_some_and(|e| e.double_strike == Some(true)) {
|
||||||
p.push_str(" style:text-line-through-type=\"double\"");
|
p.push_str(" style:text-line-through-type=\"double\"");
|
||||||
}
|
}
|
||||||
|
} else if off(|x| x.strike) {
|
||||||
|
p.push_str(" style:text-line-through-style=\"none\"");
|
||||||
}
|
}
|
||||||
if let Some(e) = extra {
|
if let Some(e) = extra {
|
||||||
if (f.sup || f.sub) && e.position.is_some() {
|
if (f.sup || f.sub) && e.position.is_some() {
|
||||||
@@ -233,8 +237,13 @@ pub(in crate::odt) fn text_props(f: &Fmt) -> String {
|
|||||||
if let Some(c) = f.background {
|
if let Some(c) = f.background {
|
||||||
p.push_str(&format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase()));
|
p.push_str(&format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase()));
|
||||||
}
|
}
|
||||||
|
// One size in WordCraft's model is every script's in ODF's, as bold is: without these,
|
||||||
|
// LibreOffice shows Asian and complex-script text at the default size whatever was chosen.
|
||||||
if let Some(s) = f.size {
|
if let Some(s) = f.size {
|
||||||
p.push_str(&format!(" fo:font-size=\"{}\"", pt(s)));
|
p.push_str(&format!(" fo:font-size=\"{0}\" style:font-size-asian=\"{0}\"", pt(s)));
|
||||||
|
if f.extra.as_deref().and_then(|x| x.size_cs).is_none() {
|
||||||
|
p.push_str(&format!(" style:font-size-complex=\"{}\"", pt(s)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if f.code {
|
if f.code {
|
||||||
p.push_str(&format!(" fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\"", model::MONO_FONT));
|
p.push_str(&format!(" fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\"", model::MONO_FONT));
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
|
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
|
||||||
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
|
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
|
||||||
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
|
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
|
||||||
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables
|
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables
|
||||||
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading).
|
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and
|
||||||
|
//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` …
|
||||||
|
//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}`
|
||||||
|
//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`).
|
||||||
//!
|
//!
|
||||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
|
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects
|
||||||
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
|
//! are skipped; headers, footers, notes and comments are read into stories of their own),
|
||||||
|
//! `\par \line \tab \page`, `\uN` with `\ucN`
|
||||||
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
|
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
|
||||||
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
|
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
|
||||||
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
|
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
|
||||||
@@ -16,13 +20,16 @@
|
|||||||
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
|
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
|
||||||
//! above, stylesheet-based
|
//! above, stylesheet-based
|
||||||
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
|
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
|
||||||
//! tables. Group nesting and picture sizes are capped.
|
//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date,
|
||||||
|
//! text, the commented range). Group nesting, picture sizes and the number of notes and comments
|
||||||
|
//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside
|
||||||
|
//! headers, footers and other notes, tables inside note and comment texts (flattened).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use wordcraft_doc::props::VAlign;
|
use wordcraft_doc::props::VAlign;
|
||||||
use wordcraft_doc::{Align, Document, Rgb};
|
use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
|
||||||
|
|
||||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
|
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/// An ISO 8601 date-time from year, month, day, hour and minute.
|
||||||
|
pub(super) fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String {
|
||||||
|
format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59))
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn rtf_date(s: &str) -> Option<String> {
|
pub(super) fn rtf_date(s: &str) -> Option<String> {
|
||||||
let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<u32>().ok());
|
let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::<u32>().ok());
|
||||||
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use super::date::{iso_to_dttm, rtf_date};
|
use super::date::{iso_to_dttm, rtf_date};
|
||||||
|
use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS};
|
||||||
use super::styles::{style_fmt, styled};
|
use super::styles::{style_fmt, styled};
|
||||||
use super::{Align, Document, FBlock, FTable, Fmt, Inline, Kind, Meta, Para, RevMark, Rgb, VAlign, model};
|
use super::{
|
||||||
|
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------
|
||||||
// Export
|
// Export
|
||||||
@@ -66,7 +69,30 @@ impl Tables {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a place marker in the exported text stands for (see [`MARK_OPEN`]).
|
||||||
|
pub(super) enum Mark {
|
||||||
|
Note {
|
||||||
|
endnote: bool,
|
||||||
|
blocks: Vec<FBlock>,
|
||||||
|
},
|
||||||
|
RangeStart(u32),
|
||||||
|
/// The end of a comment's range; the first end of each comment also carries its annotation.
|
||||||
|
RangeEnd {
|
||||||
|
id: u32,
|
||||||
|
ann: Option<Annotation>,
|
||||||
|
},
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct Annotation {
|
||||||
|
author: String,
|
||||||
|
initials: String,
|
||||||
|
date: String,
|
||||||
|
blocks: Vec<FBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct Writer {
|
pub(super) struct Writer {
|
||||||
|
pub(super) marks: Vec<Mark>,
|
||||||
pub(super) t: Tables,
|
pub(super) t: Tables,
|
||||||
/// List definitions: per list, the ordered flag per level.
|
/// List definitions: per list, the ordered flag per level.
|
||||||
pub(super) lists: Vec<[bool; 9]>,
|
pub(super) lists: Vec<[bool; 9]>,
|
||||||
@@ -81,6 +107,121 @@ pub(super) struct Writer {
|
|||||||
pub(super) sect_i: usize,
|
pub(super) sect_i: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool {
|
||||||
|
bl.iter().any(|b| match &**b {
|
||||||
|
Block::Para(p) => {
|
||||||
|
p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }))
|
||||||
|
}
|
||||||
|
Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of `doc` whose note references and comment range ends are place markers in the text
|
||||||
|
/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither.
|
||||||
|
fn mark_doc(doc: &Document) -> Option<(Document, Vec<Mark>)> {
|
||||||
|
if !has_marks(&doc.body, 0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() };
|
||||||
|
let body = m.blocks(&doc.body, None, 0);
|
||||||
|
let mut d = doc.clone();
|
||||||
|
d.body = body;
|
||||||
|
Some((d, m.marks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The story id standing for "inside a note or comment": no nested notes, no comment ranges.
|
||||||
|
const IN_STORY: u32 = u32::MAX;
|
||||||
|
|
||||||
|
struct Marker<'a> {
|
||||||
|
doc: &'a Document,
|
||||||
|
marks: Vec<Mark>,
|
||||||
|
annotated: HashSet<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Marker<'_> {
|
||||||
|
fn token(&mut self, m: Mark) -> String {
|
||||||
|
self.marks.push(m);
|
||||||
|
self.marks.len().saturating_sub(1).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs.
|
||||||
|
fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec<FBlock>) {
|
||||||
|
for b in bl.iter().take(MAX_NOTE_BLOCKS) {
|
||||||
|
match &**b {
|
||||||
|
Block::Para(p) => {
|
||||||
|
let q = self.para(p, Some(note));
|
||||||
|
out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para));
|
||||||
|
}
|
||||||
|
Block::Table(t) if depth < 4 => {
|
||||||
|
for c in t.rows.iter().flat_map(|r| r.cells.iter()) {
|
||||||
|
self.story(&c.blocks, note, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Block::Table(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocks(&mut self, bl: &Blocks, note: Option<u32>, depth: usize) -> Blocks {
|
||||||
|
bl.iter()
|
||||||
|
.map(|b| match &**b {
|
||||||
|
Block::Para(p) if p.objects.is_empty() => b.clone(),
|
||||||
|
Block::Para(p) => Arc::new(Block::Para(self.para(p, note))),
|
||||||
|
Block::Table(t) if depth < 8 => {
|
||||||
|
let mut t = t.clone();
|
||||||
|
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||||
|
c.blocks = self.blocks(&c.blocks, note, depth + 1);
|
||||||
|
}
|
||||||
|
Arc::new(Block::Table(t))
|
||||||
|
}
|
||||||
|
Block::Table(_) => b.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `p` with its note references and comment range ends replaced by place markers. `note` is
|
||||||
|
/// the note being written, when `p` is part of one.
|
||||||
|
fn para(&mut self, p: &Paragraph, note: Option<u32>) -> Paragraph {
|
||||||
|
let mut q = p.clone();
|
||||||
|
for off in p.object_offsets().into_iter().rev() {
|
||||||
|
let Some(obj) = q.object_at(off).cloned() else { continue };
|
||||||
|
let tok = match obj {
|
||||||
|
InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")),
|
||||||
|
InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()),
|
||||||
|
InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
self.story(&part.blocks, id, 0, &mut blocks);
|
||||||
|
self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks })
|
||||||
|
}),
|
||||||
|
InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))),
|
||||||
|
InlineObject::CommentEnd { id } if note.is_none() => {
|
||||||
|
let ann = if self.annotated.insert(id) { self.annotation(id) } else { None };
|
||||||
|
Some(self.token(Mark::RangeEnd { id, ann }))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let Some(tok) = tok else { continue };
|
||||||
|
let end = off + wordcraft_doc::para::OBJ.len_utf8();
|
||||||
|
let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") };
|
||||||
|
if q.delete(off, end).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q
|
||||||
|
}
|
||||||
|
|
||||||
|
fn annotation(&mut self, id: u32) -> Option<Annotation> {
|
||||||
|
let c = self.doc.comments.get(&id)?;
|
||||||
|
let part = self.doc.parts.get(&c.part)?;
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
self.story(&part.blocks, IN_STORY, 0, &mut blocks);
|
||||||
|
Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `\sectd` and a section's formatting, in the words the reader reads back.
|
/// `\sectd` and a section's formatting, in the words the reader reads back.
|
||||||
fn sect_words(s: &wordcraft_doc::SectionProps) -> String {
|
fn sect_words(s: &wordcraft_doc::SectionProps) -> String {
|
||||||
use wordcraft_doc::section::{NumFormat, SectionStart};
|
use wordcraft_doc::section::{NumFormat, SectionStart};
|
||||||
@@ -232,7 +373,68 @@ impl Writer {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A run of text; place markers in it become notes and comment ranges.
|
||||||
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
|
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
|
||||||
|
if !t.contains(MARK_OPEN) {
|
||||||
|
return self.run_plain(t, f, out);
|
||||||
|
}
|
||||||
|
let mut rest = t;
|
||||||
|
while !rest.is_empty() {
|
||||||
|
let Some((pre, after)) = rest.split_once(MARK_OPEN) else {
|
||||||
|
self.run_plain(rest, f, out);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !pre.is_empty() {
|
||||||
|
self.run_plain(pre, f, out);
|
||||||
|
}
|
||||||
|
let Some((tok, tail)) = after.split_once(MARK_CLOSE) else {
|
||||||
|
self.run_plain(after, f, out);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.mark(tok, out);
|
||||||
|
rest = tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`.
|
||||||
|
fn mark(&mut self, tok: &str, out: &mut String) {
|
||||||
|
if tok == "S" {
|
||||||
|
out.push_str("{\\super\\chftn}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(m) = tok.parse::<usize>().ok().and_then(|i| self.marks.get_mut(i)) else { return };
|
||||||
|
match std::mem::replace(m, Mark::Done) {
|
||||||
|
Mark::Note { endnote, blocks } => {
|
||||||
|
out.push_str("{\\super\\chftn}{\\footnote");
|
||||||
|
if endnote {
|
||||||
|
out.push_str("\\ftnalt");
|
||||||
|
}
|
||||||
|
out.push(' ');
|
||||||
|
self.blocks(&blocks, false, "\\par", out, 0);
|
||||||
|
out.push('}');
|
||||||
|
}
|
||||||
|
Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")),
|
||||||
|
Mark::RangeEnd { id, ann } => {
|
||||||
|
out.push_str(&format!("{{\\*\\atrfend {id}}}"));
|
||||||
|
if let Some(a) = ann {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}",
|
||||||
|
esc(&a.initials),
|
||||||
|
esc(&a.author)
|
||||||
|
));
|
||||||
|
if let Some(d) = rtf_date(&a.date) {
|
||||||
|
out.push_str(&format!("{{\\*\\atndate {d}}}"));
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
self.blocks(&a.blocks, false, "\\par", out, 0);
|
||||||
|
out.push('}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mark::Done => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) {
|
||||||
let mut ctl = String::new();
|
let mut ctl = String::new();
|
||||||
if let Some(c) = &f.fmt_change {
|
if let Some(c) = &f.fmt_change {
|
||||||
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
|
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
|
||||||
@@ -685,9 +887,15 @@ fn level_def(ordered: bool, l: usize) -> String {
|
|||||||
|
|
||||||
/// RTF text of a document.
|
/// RTF text of a document.
|
||||||
pub fn export(doc: &Document) -> String {
|
pub fn export(doc: &Document) -> String {
|
||||||
|
let (marked, marks) = match mark_doc(doc) {
|
||||||
|
Some((d, m)) => (Some(d), m),
|
||||||
|
None => (None, Vec::new()),
|
||||||
|
};
|
||||||
|
let doc = marked.as_ref().unwrap_or(doc);
|
||||||
let flow = model::from_doc_tracked(doc);
|
let flow = model::from_doc_tracked(doc);
|
||||||
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
|
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());
|
||||||
let mut w = Writer {
|
let mut w = Writer {
|
||||||
|
marks,
|
||||||
t: Tables { fonts: vec![base_font], colors: Vec::new() },
|
t: Tables { fonts: vec![base_font], colors: Vec::new() },
|
||||||
lists: Vec::new(),
|
lists: Vec::new(),
|
||||||
authors: None,
|
authors: None,
|
||||||
|
|||||||
+161
-11
@@ -1,7 +1,6 @@
|
|||||||
use super::reader::Reader;
|
use super::reader::Reader;
|
||||||
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
|
use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom};
|
||||||
use super::{Cell, Document, FBlock, Flow, Para, model};
|
use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model};
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// Parse RTF bytes into the flow model.
|
/// Parse RTF bytes into the flow model.
|
||||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||||
@@ -11,7 +10,7 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
|||||||
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
|
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
|
||||||
/// is the font of all text that doesn't select one.
|
/// is the font of all text that doesn't select one.
|
||||||
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
|
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
|
||||||
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
|
parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything an import takes from an RTF file.
|
/// Everything an import takes from an RTF file.
|
||||||
@@ -23,14 +22,19 @@ struct Parsed {
|
|||||||
default_font: Option<String>,
|
default_font: Option<String>,
|
||||||
/// Each section's headers and footers, in order, the last section's last.
|
/// Each section's headers and footers, in order, the last section's last.
|
||||||
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
|
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
|
||||||
|
notes: Vec<NoteData>,
|
||||||
|
comments: Vec<CommentData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for
|
||||||
|
/// [`import`] to turn into objects); without it they are skipped.
|
||||||
|
fn parse_full(bytes: &[u8], marks: bool) -> Result<Parsed, String> {
|
||||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||||
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
|
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
|
||||||
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
|
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
|
||||||
}
|
}
|
||||||
let mut r = Reader::default();
|
let mut r = Reader::default();
|
||||||
|
r.marks_on = marks;
|
||||||
let mut i = start;
|
let mut i = start;
|
||||||
let mut first_in_group = false;
|
let mut first_in_group = false;
|
||||||
let mut starred = false;
|
let mut starred = false;
|
||||||
@@ -106,7 +110,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if r.skip_chars > 0
|
if r.skip_chars > 0
|
||||||
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
|
&& matches!(
|
||||||
|
r.st.dest,
|
||||||
|
Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl | Dest::Atn(_)
|
||||||
|
)
|
||||||
{
|
{
|
||||||
r.skip_chars -= 1;
|
r.skip_chars -= 1;
|
||||||
first_in_group = false;
|
first_in_group = false;
|
||||||
@@ -128,7 +135,13 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||||||
}
|
}
|
||||||
match n {
|
match n {
|
||||||
b'\'' => {
|
b'\'' => {
|
||||||
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
|
// `from_str_radix` takes a leading `+`: a pair that isn't both hex digits
|
||||||
|
// is not an escape at all.
|
||||||
|
let h = bytes
|
||||||
|
.get(i + 2..i + 4)
|
||||||
|
.and_then(|h| std::str::from_utf8(h).ok())
|
||||||
|
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||||
|
.and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||||
match h {
|
match h {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
if r.st.dest == Dest::Pict {
|
if r.st.dest == Dest::Pict {
|
||||||
@@ -143,7 +156,17 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||||||
}
|
}
|
||||||
i += 4;
|
i += 4;
|
||||||
}
|
}
|
||||||
None => i += 2,
|
// Not two hex digits: the escape is malformed. Consume the escape and
|
||||||
|
// any hex digits of a truncated pair, and leave every other byte,
|
||||||
|
// `}` or a control word's `\`, to be read as the structure it is.
|
||||||
|
None => {
|
||||||
|
i += 2;
|
||||||
|
let mut n = 0;
|
||||||
|
while n < 2 && bytes.get(i).is_some_and(u8::is_ascii_hexdigit) {
|
||||||
|
i += 1;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b'*' => {
|
b'*' => {
|
||||||
@@ -206,8 +229,10 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn finalize(mut r: Reader) -> Parsed {
|
fn finalize(mut r: Reader) -> Parsed {
|
||||||
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
|
// A multibyte run ends here, and so does a note, comment, header or footer left open by a
|
||||||
|
// truncated file.
|
||||||
r.finish_multibyte();
|
r.finish_multibyte();
|
||||||
|
r.end_sub();
|
||||||
r.end_header_footer();
|
r.end_header_footer();
|
||||||
if r.para_has || !r.para.inlines.is_empty() {
|
if r.para_has || !r.para.inlines.is_empty() {
|
||||||
r.end_para(false);
|
r.end_para(false);
|
||||||
@@ -234,7 +259,7 @@ fn finalize(mut r: Reader) -> Parsed {
|
|||||||
blocks.push(FBlock::Para(Para::default()));
|
blocks.push(FBlock::Para(Para::default()));
|
||||||
}
|
}
|
||||||
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
|
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
|
||||||
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }
|
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers, notes: r.notes, comments: r.comments }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
||||||
@@ -277,7 +302,7 @@ pub(super) fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f3
|
|||||||
/// Parse RTF bytes into a document.
|
/// Parse RTF bytes into a document.
|
||||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||||
model::begin_import();
|
model::begin_import();
|
||||||
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
|
let Parsed { flow, last_section, default_font, headers_footers, notes, comments, .. } = parse_full(bytes, true)?;
|
||||||
let mut doc = model::to_doc_in(&flow, last_section);
|
let mut doc = model::to_doc_in(&flow, last_section);
|
||||||
// Each section's own headers and footers; those it leaves out stay linked to the previous.
|
// Each section's own headers and footers; those it leaves out stay linked to the previous.
|
||||||
let mut sets = Vec::with_capacity(headers_footers.len());
|
let mut sets = Vec::with_capacity(headers_footers.len());
|
||||||
@@ -309,6 +334,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
|||||||
if let Some((h, f)) = sets.next() {
|
if let Some((h, f)) = sets.next() {
|
||||||
(doc.last_section.headers, doc.last_section.footers) = (h, f);
|
(doc.last_section.headers, doc.last_section.footers) = (h, f);
|
||||||
}
|
}
|
||||||
|
place_notes_and_comments(&mut doc, notes, comments);
|
||||||
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
||||||
// wants beyond that it states on each paragraph.
|
// wants beyond that it states on each paragraph.
|
||||||
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
|
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
|
||||||
@@ -320,3 +346,127 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
|||||||
}
|
}
|
||||||
Ok(doc)
|
Ok(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn the place markers the reader left in the text into note references and comment ranges,
|
||||||
|
/// and add the notes' and comments' stories to `doc`.
|
||||||
|
fn place_notes_and_comments(doc: &mut Document, notes: Vec<NoteData>, comments: Vec<CommentData>) {
|
||||||
|
use wordcraft_doc::para::NoteKind;
|
||||||
|
use wordcraft_doc::{Comment, PartKind};
|
||||||
|
if notes.is_empty() && comments.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// (kind, part id) per note, by index.
|
||||||
|
let mut note_parts: Vec<(NoteKind, u32)> = Vec::new();
|
||||||
|
for n in notes {
|
||||||
|
let (pk, nk, style) =
|
||||||
|
if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") };
|
||||||
|
let mut blocks = model::part_blocks(doc, &n.blocks);
|
||||||
|
for b in &mut blocks {
|
||||||
|
if let Block::Para(p) = Arc::make_mut(b)
|
||||||
|
&& p.props.style.is_none()
|
||||||
|
{
|
||||||
|
p.props.style = Some(style.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note_parts.push((nk, doc.add_part(pk, blocks)));
|
||||||
|
}
|
||||||
|
// RTF range id to the comment's id in the document.
|
||||||
|
let mut by_rtf: HashMap<i64, u32> = HashMap::new();
|
||||||
|
for (k, c) in comments.into_iter().enumerate() {
|
||||||
|
let id = k as u32;
|
||||||
|
let blocks = model::part_blocks(doc, &c.blocks);
|
||||||
|
let part = doc.add_part(PartKind::Comment, blocks);
|
||||||
|
doc.comments
|
||||||
|
.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() });
|
||||||
|
by_rtf.insert(i64::from(c.rtf_id), id);
|
||||||
|
}
|
||||||
|
let ctx = Placer { note_parts, by_rtf };
|
||||||
|
let mut body = std::mem::take(&mut doc.body);
|
||||||
|
ctx.blocks(&mut body, None, 0);
|
||||||
|
doc.body = body;
|
||||||
|
let ids: Vec<u32> = doc.parts.keys().copied().collect();
|
||||||
|
for id in ids {
|
||||||
|
let Some(part) = doc.parts.get_mut(&id) else { continue };
|
||||||
|
let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied();
|
||||||
|
ctx.blocks(&mut part.blocks, own, 0);
|
||||||
|
// A note starts with its own number, as Word's do.
|
||||||
|
if let Some((kind, pid)) = own
|
||||||
|
&& let Some(first) = part.blocks.first_mut()
|
||||||
|
&& let Block::Para(p) = Arc::make_mut(first)
|
||||||
|
&& !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid))
|
||||||
|
{
|
||||||
|
let props = Placer::ref_props(kind);
|
||||||
|
if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() {
|
||||||
|
// The paragraph can't take it; the note simply has no number mark.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Placer {
|
||||||
|
note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>,
|
||||||
|
by_rtf: HashMap<i64, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Placer {
|
||||||
|
fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps {
|
||||||
|
let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" };
|
||||||
|
wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) {
|
||||||
|
for b in bl.iter_mut() {
|
||||||
|
let needs = match &**b {
|
||||||
|
Block::Para(p) => p.text.contains(MARK_OPEN),
|
||||||
|
Block::Table(_) => depth < 8,
|
||||||
|
};
|
||||||
|
if !needs {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match Arc::make_mut(b) {
|
||||||
|
Block::Para(p) => self.para(p, own),
|
||||||
|
Block::Table(t) => {
|
||||||
|
for r in &mut t.rows {
|
||||||
|
for c in &mut r.cells {
|
||||||
|
self.blocks(&mut c.blocks, own, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) {
|
||||||
|
// Every pass removes one marker, so this ends; the bound is only a seat belt.
|
||||||
|
for _ in 0..100_000 {
|
||||||
|
let Some(a) = p.text.find(MARK_OPEN) else { return };
|
||||||
|
let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return };
|
||||||
|
let end = a + len + MARK_CLOSE.len_utf8();
|
||||||
|
let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string();
|
||||||
|
let mut chars = inner.chars();
|
||||||
|
let tag = chars.next().unwrap_or(' ');
|
||||||
|
let n = chars.as_str().parse::<i64>().unwrap_or(i64::MIN);
|
||||||
|
let (obj, props) = match tag {
|
||||||
|
'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) {
|
||||||
|
Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
|
||||||
|
None => (None, Default::default()),
|
||||||
|
},
|
||||||
|
'S' => match own {
|
||||||
|
Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
|
||||||
|
None => (None, Default::default()),
|
||||||
|
},
|
||||||
|
'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()),
|
||||||
|
'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()),
|
||||||
|
_ => (None, Default::default()),
|
||||||
|
};
|
||||||
|
if p.delete(a, end).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(o) = obj
|
||||||
|
&& p.insert_object(a, o, &props).is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use super::date::dttm_to_iso;
|
use super::date::{dttm_to_iso, iso_date};
|
||||||
use super::parse::column_grid;
|
use super::parse::column_grid;
|
||||||
use super::state::{CellDef, Dest, HeaderFooter, MAX_GROUPS, MAX_REV_AUTHORS, MAX_SECTIONS, Output, PageGeom, SecFmt, St};
|
use super::state::{
|
||||||
|
CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData,
|
||||||
|
Output, PageGeom, SecFmt, St, Sub,
|
||||||
|
};
|
||||||
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
|
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
|
||||||
use super::{Arc, Cell, FBlock, FTable, HashMap, Inline, Kind, Meta, Para, Rgb, model};
|
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(super) struct Reader {
|
pub(super) struct Reader {
|
||||||
@@ -91,6 +94,22 @@ pub(super) struct Reader {
|
|||||||
pub(super) in_creatim: bool,
|
pub(super) in_creatim: bool,
|
||||||
pub(super) rule_pending: bool,
|
pub(super) rule_pending: bool,
|
||||||
pub(super) info_depth: Option<usize>,
|
pub(super) info_depth: Option<usize>,
|
||||||
|
// Notes and comments (only when the caller wants them).
|
||||||
|
pub(super) marks_on: bool,
|
||||||
|
pub(super) sub: Option<Sub>,
|
||||||
|
pub(super) notes: Vec<NoteData>,
|
||||||
|
pub(super) comments: Vec<CommentData>,
|
||||||
|
pub(super) atn_text: String,
|
||||||
|
pub(super) atn_initials: String,
|
||||||
|
pub(super) atn_author: String,
|
||||||
|
pub(super) atn_date: String,
|
||||||
|
pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>,
|
||||||
|
pub(super) atn_ref: Option<i32>,
|
||||||
|
pub(super) last_end: Option<i32>,
|
||||||
|
pub(super) range_starts: HashSet<i32>,
|
||||||
|
pub(super) range_ends: HashSet<i32>,
|
||||||
|
pub(super) claimed: HashSet<i32>,
|
||||||
|
pub(super) synth: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Reader {
|
impl Reader {
|
||||||
@@ -152,12 +171,103 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A footnote, endnote or comment group starts: read it into an output of its own.
|
||||||
|
pub(super) fn start_sub(&mut self, comment: bool) {
|
||||||
|
if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES {
|
||||||
|
self.st.dest = Dest::Skip;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let saved = self.swap_output(Output::default());
|
||||||
|
self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) });
|
||||||
|
self.st.intbl = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A place marker as text in the current paragraph.
|
||||||
|
pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) {
|
||||||
|
self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt);
|
||||||
|
self.para_has = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The footnote or comment group closed: keep its text and leave a marker in the body.
|
||||||
|
pub(super) fn end_sub(&mut self) {
|
||||||
|
let Some(sub) = self.sub.take() else { return };
|
||||||
|
if self.para_has || !self.para.inlines.is_empty() {
|
||||||
|
self.end_para(false);
|
||||||
|
}
|
||||||
|
self.close_table();
|
||||||
|
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
|
||||||
|
let mut inner = self.swap_output(*sub.saved);
|
||||||
|
inner.body.truncate(MAX_NOTE_BLOCKS);
|
||||||
|
if !sub.comment {
|
||||||
|
self.push_mark('N', self.notes.len() as i64, fmt);
|
||||||
|
self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let want = self.atn_ref.take().or(self.last_end.take());
|
||||||
|
let id = match want {
|
||||||
|
Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i,
|
||||||
|
_ => {
|
||||||
|
// No range of its own: the comment sits at this point.
|
||||||
|
self.synth = self.synth.saturating_sub(1);
|
||||||
|
let id = self.synth;
|
||||||
|
self.push_mark('B', i64::from(id), fmt.clone());
|
||||||
|
self.push_mark('E', i64::from(id), fmt);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let date =
|
||||||
|
if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() };
|
||||||
|
self.atn_parts = None;
|
||||||
|
self.comments.push(CommentData {
|
||||||
|
rtf_id: id,
|
||||||
|
author: std::mem::take(&mut self.atn_author),
|
||||||
|
initials: std::mem::take(&mut self.atn_initials),
|
||||||
|
date,
|
||||||
|
blocks: inner.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…).
|
||||||
|
pub(super) fn commit_atn(&mut self, kind: u8) {
|
||||||
|
let text = std::mem::take(&mut self.atn_text);
|
||||||
|
let text = text.trim();
|
||||||
|
let id = text.parse::<i32>().ok();
|
||||||
|
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
|
||||||
|
match kind {
|
||||||
|
1 => {
|
||||||
|
if let Some(id) = id {
|
||||||
|
self.range_starts.insert(id);
|
||||||
|
self.push_mark('B', i64::from(id), fmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
if let Some(id) = id {
|
||||||
|
self.range_ends.insert(id);
|
||||||
|
self.last_end = Some(id);
|
||||||
|
self.push_mark('E', i64::from(id), fmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => self.atn_initials = text.to_string(),
|
||||||
|
4 => self.atn_author = text.to_string(),
|
||||||
|
5 => {
|
||||||
|
// Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number.
|
||||||
|
if let Some(parts) = self.atn_parts.take() {
|
||||||
|
self.atn_date = iso_date(parts);
|
||||||
|
} else if let Some(n) = text.parse::<u32>().ok().filter(|n| *n != 0) {
|
||||||
|
self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
6 => self.atn_ref = id,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
|
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
|
||||||
/// headers and footers read since the last break. Inside a header, footer or table, and past
|
/// headers and footers read since the last break. Inside a header, footer or table, and past
|
||||||
/// [`MAX_SECTIONS`], it only ends the paragraph.
|
/// [`MAX_SECTIONS`], it only ends the paragraph.
|
||||||
pub(super) fn end_section(&mut self) {
|
pub(super) fn end_section(&mut self) {
|
||||||
self.end_para_mark();
|
self.end_para_mark();
|
||||||
if self.hf.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let props = self.sec.props(&self.page);
|
let props = self.sec.props(&self.page);
|
||||||
@@ -175,7 +285,7 @@ impl Reader {
|
|||||||
let pv = p.unwrap_or(0);
|
let pv = p.unwrap_or(0);
|
||||||
// Inside a header or footer the word is read into a scratch copy and dropped.
|
// Inside a header or footer the word is read into a scratch copy and dropped.
|
||||||
let mut scratch;
|
let mut scratch;
|
||||||
let sec = if self.hf.is_some() {
|
let sec = if self.hf.is_some() || self.sub.is_some() {
|
||||||
scratch = self.sec.clone();
|
scratch = self.sec.clone();
|
||||||
&mut scratch
|
&mut scratch
|
||||||
} else {
|
} else {
|
||||||
@@ -231,6 +341,9 @@ impl Reader {
|
|||||||
self.overflow -= 1;
|
self.overflow -= 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) {
|
||||||
|
self.end_sub();
|
||||||
|
}
|
||||||
let closing = self.st.dest;
|
let closing = self.st.dest;
|
||||||
// Destination-level commits.
|
// Destination-level commits.
|
||||||
match closing {
|
match closing {
|
||||||
@@ -249,6 +362,7 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
self.in_creatim = false;
|
self.in_creatim = false;
|
||||||
}
|
}
|
||||||
|
Dest::Atn(k) => self.commit_atn(k),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
let Some(mut prev) = self.stack.pop() else { return };
|
let Some(mut prev) = self.stack.pop() else { return };
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ pub(super) const MAX_GROUPS: usize = 512;
|
|||||||
|
|
||||||
/// Largest picture we decode (bytes).
|
/// Largest picture we decode (bytes).
|
||||||
pub(super) const MAX_PICT: usize = 64 << 20;
|
pub(super) const MAX_PICT: usize = 64 << 20;
|
||||||
|
/// Most notes, and most comments, one file may bring in.
|
||||||
|
pub(super) const MAX_NOTES: usize = 5_000;
|
||||||
|
/// Most blocks kept of one note's or comment's text.
|
||||||
|
pub(super) const MAX_NOTE_BLOCKS: usize = 500;
|
||||||
|
/// Brackets of a place marker in text: a note reference or a comment range end travels through the
|
||||||
|
/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the
|
||||||
|
/// writer keep out of real text) and becomes an inline object once the document exists.
|
||||||
|
pub(super) const MARK_OPEN: char = '\u{F8F0}';
|
||||||
|
pub(super) const MARK_CLOSE: char = '\u{F8F1}';
|
||||||
|
|
||||||
/// Most sections we read (`\sect`); later breaks end paragraphs only.
|
/// Most sections we read (`\sect`); later breaks end paragraphs only.
|
||||||
pub(super) const MAX_SECTIONS: usize = 10_000;
|
pub(super) const MAX_SECTIONS: usize = 10_000;
|
||||||
@@ -25,6 +34,9 @@ pub(super) enum Dest {
|
|||||||
FldInst,
|
FldInst,
|
||||||
Pict,
|
Pict,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
/// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`,
|
||||||
|
/// 5 `\atndate`/`\atntime`, 6 `\atnref`.
|
||||||
|
Atn(u8),
|
||||||
/// The author table of tracked changes (`\revtbl`).
|
/// The author table of tracked changes (`\revtbl`).
|
||||||
RevTbl,
|
RevTbl,
|
||||||
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
|
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
|
||||||
@@ -32,6 +44,29 @@ pub(super) enum Dest {
|
|||||||
OldProps(bool),
|
OldProps(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A footnote, endnote or comment group being read into an output of its own.
|
||||||
|
pub(super) struct Sub {
|
||||||
|
pub(super) depth: usize,
|
||||||
|
pub(super) comment: bool,
|
||||||
|
pub(super) endnote: bool,
|
||||||
|
pub(super) saved: Box<Output>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A note read: its kind and text.
|
||||||
|
pub(super) struct NoteData {
|
||||||
|
pub(super) endnote: bool,
|
||||||
|
pub(super) blocks: Vec<FBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A comment read: the id its range markers carry, who wrote it and when, and its text.
|
||||||
|
pub(super) struct CommentData {
|
||||||
|
pub(super) rtf_id: i32,
|
||||||
|
pub(super) author: String,
|
||||||
|
pub(super) initials: String,
|
||||||
|
pub(super) date: String,
|
||||||
|
pub(super) blocks: Vec<FBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
|
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
|
||||||
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
|
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
|
||||||
/// formatting, once its group has been read.
|
/// formatting, once its group has been read.
|
||||||
|
|||||||
@@ -37,6 +37,73 @@ fn basic_reader() {
|
|||||||
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
|
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stylesheet_names_take_unicode_escapes() {
|
||||||
|
// \u116?=t \u105?=i \u116?=t \u108?=l \u101?=e — how Word writes a non-Latin style name.
|
||||||
|
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u116?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert_eq!(texts(&f)[0], "Title:T");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stylesheet_names_take_negative_escapes() {
|
||||||
|
// 't' as a signed 16-bit escape: 116 - 65536 = -65420.
|
||||||
|
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \u-65420?\u105?\u116?\u108?\u101?;}}\pard\s2 T\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert_eq!(texts(&f)[0], "Title:T");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stylesheet_names_honour_the_uc_fallback_count() {
|
||||||
|
// \uc2: two fallback bytes follow each \uN? — here a second `?`.
|
||||||
|
let rtf = br"{\rtf1\ansi{\stylesheet{\s0 Normal;}{\s2 \uc2\u116??\u105??\u116??\u108??\u101??;}}\pard\s2 T\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert_eq!(texts(&f)[0], "Title:T");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_hex_escape_leaves_no_stray_text() {
|
||||||
|
// A `\'` that is not followed by two hex digits is malformed: the escape itself goes, any
|
||||||
|
// hex digits of a truncated pair go with it, and everything else — non-hex bytes, `}` —
|
||||||
|
// is read as what it is. `\'gZ` keeps "gZ" as text; `\'4}` keeps the closing brace.
|
||||||
|
let rtf = br"{\rtf1\ansi \'gZok\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert_eq!(texts(&f)[0], "Normal:gZok");
|
||||||
|
|
||||||
|
// The bytes after a truncated pair must not swallow the group's closing brace or the next
|
||||||
|
// control word: the group and its destination state end where they should. Paragraph text
|
||||||
|
// alone would not show that: `survivor` reads the same inside the bold group, so the run's
|
||||||
|
// formatting is checked too.
|
||||||
|
let rtf = br"{\rtf1\ansi{\b \'4}survivor\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert!(texts(&f).iter().any(|t| t.contains("survivor")), "{:?}", texts(&f));
|
||||||
|
let survivor_bold = f.blocks.iter().find_map(|b| match b {
|
||||||
|
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||||||
|
Inline::Text(t, fmt) if t.contains("survivor") => Some(fmt.bold),
|
||||||
|
_ => None,
|
||||||
|
}),
|
||||||
|
FBlock::Table(_) => None,
|
||||||
|
});
|
||||||
|
assert_eq!(survivor_bold, Some(false), "survivor must not be inside the bold group: {:?}", f.blocks);
|
||||||
|
// The truncated pair's hex digit goes with the escape: no stray "4" is left in the text.
|
||||||
|
assert!(!texts(&f).iter().any(|t| t.contains('4')), "{:?}", texts(&f));
|
||||||
|
|
||||||
|
// A leading `+` is not a hex digit: `\'+4` is no escape: it stays text, not the byte 0x04.
|
||||||
|
let rtf = br"{\rtf1\ansi \'+4z\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert_eq!(texts(&f)[0], "Normal:+4z");
|
||||||
|
|
||||||
|
let rtf = br"{\rtf1\ansi{\'}}after\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert!(texts(&f).iter().any(|t| t.contains("after")), "{:?}", texts(&f));
|
||||||
|
|
||||||
|
// A hex digit that starts a valid pair with the byte after it is part of the escape:
|
||||||
|
// \'4} reads '4' as the escape's byte and leaves `}` to close the group.
|
||||||
|
let rtf = br"{\rtf1\ansi\'4}x\par}";
|
||||||
|
let f = parse(rtf).unwrap();
|
||||||
|
assert!(texts(&f).iter().any(|t| t.contains('x') && !t.contains('}')), "{:?}", texts(&f));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unicode_fallback_uses_the_declared_count() {
|
fn unicode_fallback_uses_the_declared_count() {
|
||||||
for (rtf, expected) in [
|
for (rtf, expected) in [
|
||||||
@@ -1097,6 +1164,7 @@ fn old_formatting_in_a_code_paragraph_has_the_code_font() {
|
|||||||
let mut p = Para { kind: Kind::Code, ..Default::default() };
|
let mut p = Para { kind: Kind::Code, ..Default::default() };
|
||||||
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
|
p.push_text("x", &Fmt { font: Some("Arial".into()), fmt_change: Some(Box::new(FmtChange { mark, old: Fmt::default() })), ..Default::default() });
|
||||||
let mut w = Writer {
|
let mut w = Writer {
|
||||||
|
marks: Vec::new(),
|
||||||
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
|
t: Tables { fonts: vec!["Aptos".into()], colors: Vec::new() },
|
||||||
lists: Vec::new(),
|
lists: Vec::new(),
|
||||||
authors: None,
|
authors: None,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::state::{Dest, MAX_PICT, St};
|
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
|
||||||
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
|
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -196,6 +196,11 @@ impl Reader {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut f = self.st.fmt.clone();
|
let mut f = self.st.fmt.clone();
|
||||||
|
if s.contains([MARK_OPEN, MARK_CLOSE]) {
|
||||||
|
self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f);
|
||||||
|
self.para_has = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let r = self.st.rev;
|
let r = self.st.rev;
|
||||||
if r.ins {
|
if r.ins {
|
||||||
f.ins = Some(self.rev_mark(r.auth, r.dttm));
|
f.ins = Some(self.rev_mark(r.auth, r.dttm));
|
||||||
@@ -245,6 +250,11 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Dest::Info(_) => self.info_text.push_str(s),
|
Dest::Info(_) => self.info_text.push_str(s),
|
||||||
|
Dest::Atn(_) => {
|
||||||
|
if self.atn_text.len() < 256 {
|
||||||
|
self.atn_text.push_str(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
Dest::FldInst => {
|
Dest::FldInst => {
|
||||||
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
|
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
|
||||||
&& f.len() < 4096
|
&& f.len() < 4096
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ impl Reader {
|
|||||||
if w == "shppict" || self.start_header_footer_word(w) {
|
if w == "shppict" || self.start_header_footer_word(w) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.start_note_word(w) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let dest = self.destination_for_word(w);
|
let dest = self.destination_for_word(w);
|
||||||
if let Some(d) = dest {
|
if let Some(d) = dest {
|
||||||
match d {
|
match d {
|
||||||
@@ -295,6 +298,22 @@ impl Reader {
|
|||||||
// Transparent wrapper: read the nested pict without changing the destination.
|
// Transparent wrapper: read the nested pict without changing the destination.
|
||||||
"pict" => Some(Dest::Pict),
|
"pict" => Some(Dest::Pict),
|
||||||
"bkmkstart" => Some(Dest::Bookmark),
|
"bkmkstart" => Some(Dest::Bookmark),
|
||||||
|
"atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref"
|
||||||
|
if self.marks_on
|
||||||
|
&& self.hf.is_none()
|
||||||
|
&& self.st.dest == Dest::Body
|
||||||
|
&& self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) =>
|
||||||
|
{
|
||||||
|
self.atn_text.clear();
|
||||||
|
Some(Dest::Atn(match w {
|
||||||
|
"atrfstart" => 1,
|
||||||
|
"atrfend" => 2,
|
||||||
|
"atnid" => 3,
|
||||||
|
"atnauthor" => 4,
|
||||||
|
"atndate" | "atntime" => 5,
|
||||||
|
_ => 6,
|
||||||
|
}))
|
||||||
|
}
|
||||||
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
|
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
|
||||||
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
|
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
|
||||||
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
|
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
|
||||||
@@ -309,6 +328,32 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and
|
||||||
|
/// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number).
|
||||||
|
fn start_note_word(&mut self, word: &str) -> bool {
|
||||||
|
if self.st.dest == Dest::Body {
|
||||||
|
match word {
|
||||||
|
"ftnalt" => {
|
||||||
|
if let Some(sub) = self.sub.as_mut() {
|
||||||
|
sub.endnote = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
"chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => {
|
||||||
|
let f = self.st.fmt.clone();
|
||||||
|
self.push_mark('S', 0, f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.start_sub(word == "annotation");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
fn start_header_footer_word(&mut self, word: &str) -> bool {
|
fn start_header_footer_word(&mut self, word: &str) -> bool {
|
||||||
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|
||||||
|| self.hf.is_some()
|
|| self.hf.is_some()
|
||||||
@@ -358,7 +403,8 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
Dest::StyleSheet => {
|
// Style names take Unicode escapes (with fallback skipping) like the body.
|
||||||
|
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
|
||||||
let def = &mut self.style_def_cur;
|
let def = &mut self.style_def_cur;
|
||||||
match word {
|
match word {
|
||||||
"s" | "cs" | "ds" | "ts" => {
|
"s" | "cs" | "ds" | "ts" => {
|
||||||
@@ -375,6 +421,7 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
Dest::StyleSheet => false,
|
||||||
Dest::ListTable => {
|
Dest::ListTable => {
|
||||||
self.list_table_word(word, value);
|
self.list_table_word(word, value);
|
||||||
true
|
true
|
||||||
@@ -398,6 +445,12 @@ impl Reader {
|
|||||||
self.picture_word(word, value);
|
self.picture_word(word, value);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
Dest::Atn(kind) => {
|
||||||
|
if kind == 5 {
|
||||||
|
self.record_atn_part(word, value);
|
||||||
|
}
|
||||||
|
!matches!(word, "u" | "uc")
|
||||||
|
}
|
||||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
|
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
|
||||||
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
|
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
|
||||||
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
|
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
|
||||||
@@ -421,6 +474,19 @@ impl Reader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_atn_part(&mut self, word: &str, value: i32) {
|
||||||
|
let v = value.clamp(0, 9999) as u32;
|
||||||
|
let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0));
|
||||||
|
match word {
|
||||||
|
"yr" => c.0 = v,
|
||||||
|
"mo" => c.1 = v,
|
||||||
|
"dy" => c.2 = v,
|
||||||
|
"hr" => c.3 = v,
|
||||||
|
"min" => c.4 = v,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn font_table_word(&mut self, word: &str, value: i32) {
|
fn font_table_word(&mut self, word: &str, value: i32) {
|
||||||
match word {
|
match word {
|
||||||
"f" => {
|
"f" => {
|
||||||
|
|||||||
@@ -879,6 +879,142 @@ fn exported_links_keep_only_safe_targets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// RTF footnotes, endnotes and comments (#1053)
|
||||||
|
|
||||||
|
/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body.
|
||||||
|
fn rtf_notes(d: &Document) -> (Vec<String>, Vec<String>) {
|
||||||
|
use wordcraft_doc::PartKind;
|
||||||
|
let story = |bl: &wordcraft_doc::Blocks| {
|
||||||
|
bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::<Vec<_>>().join("|")
|
||||||
|
};
|
||||||
|
let notes = d
|
||||||
|
.parts
|
||||||
|
.values()
|
||||||
|
.filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote))
|
||||||
|
.map(|p| format!("{:?}:{}", p.kind, story(&p.blocks)))
|
||||||
|
.collect();
|
||||||
|
let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect();
|
||||||
|
(notes, comments)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtf_footnote_and_endnote_round_trip() {
|
||||||
|
use wordcraft_doc::para::NoteKind;
|
||||||
|
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}
|
||||||
|
\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta
|
||||||
|
gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}";
|
||||||
|
let d = import("rtf", rtf).unwrap().unwrap();
|
||||||
|
let body = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||||
|
let refs: Vec<NoteKind> =
|
||||||
|
body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect();
|
||||||
|
assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]);
|
||||||
|
assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta.");
|
||||||
|
let (notes, _) = rtf_notes(&d);
|
||||||
|
assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]);
|
||||||
|
// The note's own number sits at the start of its text, and its first words are bold.
|
||||||
|
let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap();
|
||||||
|
let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap();
|
||||||
|
assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. })));
|
||||||
|
// Out and in again keeps both.
|
||||||
|
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||||
|
assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}");
|
||||||
|
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||||
|
assert_eq!(rtf_notes(&d2).0, notes);
|
||||||
|
let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||||
|
assert_eq!(body2.objects.len(), 2);
|
||||||
|
assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta.");
|
||||||
|
// The flow reader (no document) still just skips them.
|
||||||
|
let flow = crate::rtf::parse(rtf).unwrap();
|
||||||
|
assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtf_comment_round_trip_with_author_and_date() {
|
||||||
|
let rtf = br"{\rtf1\ansi
|
||||||
|
\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par
|
||||||
|
\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}";
|
||||||
|
let d = import("rtf", rtf).unwrap().unwrap();
|
||||||
|
assert_eq!(d.comments.len(), 2);
|
||||||
|
let c = d.comments.values().next().unwrap();
|
||||||
|
assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z"));
|
||||||
|
assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]);
|
||||||
|
let p = d.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||||
|
assert_eq!(p.plain_text().trim(), "Before commented words after.");
|
||||||
|
// The range wraps exactly the commented words.
|
||||||
|
let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]);
|
||||||
|
assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. })));
|
||||||
|
assert_eq!(p.text.get(s + 3..e), Some("commented words"));
|
||||||
|
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||||
|
assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}");
|
||||||
|
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||||
|
assert_eq!(d2.comments.len(), 2);
|
||||||
|
let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap());
|
||||||
|
assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date));
|
||||||
|
assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]);
|
||||||
|
let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||||
|
assert_eq!(p2.objects.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtf_hostile_notes_and_comments_do_not_panic() {
|
||||||
|
// Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting.
|
||||||
|
let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}";
|
||||||
|
for n in 0..good.len() {
|
||||||
|
let _ = import("rtf", &good[..n]);
|
||||||
|
}
|
||||||
|
let junk: [&[u8]; 6] = [
|
||||||
|
br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}",
|
||||||
|
br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}",
|
||||||
|
br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}",
|
||||||
|
br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}",
|
||||||
|
b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}",
|
||||||
|
b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}",
|
||||||
|
];
|
||||||
|
for j in junk {
|
||||||
|
let d = import("rtf", j).unwrap().unwrap();
|
||||||
|
// Whatever came in, a second trip through RTF is fine too.
|
||||||
|
let out = export("rtf", &d).unwrap().unwrap();
|
||||||
|
assert!(import("rtf", &out).unwrap().is_ok());
|
||||||
|
}
|
||||||
|
// Many notes: bounded.
|
||||||
|
let mut many = String::from("{\\rtf1 ");
|
||||||
|
for _ in 0..6000 {
|
||||||
|
many.push_str("{\\footnote x}");
|
||||||
|
}
|
||||||
|
many.push('}');
|
||||||
|
let d = import("rtf", many.as_bytes()).unwrap().unwrap();
|
||||||
|
assert!(d.parts.len() <= 5000);
|
||||||
|
// Private-use marker characters in ordinary text stay text.
|
||||||
|
let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap();
|
||||||
|
assert!(d.parts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rtf_notes_comments_tracked_changes_and_sections_work_together() {
|
||||||
|
// Two sections; the second holds a footnote, a comment and a tracked insertion, and has its
|
||||||
|
// own footer. A `\sect` inside the footnote's text must not end a section.
|
||||||
|
let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\revtbl {Unknown;}{Alice;}}
|
||||||
|
\sectd{\footerr \pard Foot one\par}\pard First section.\par \sect
|
||||||
|
\sectd\lndscpsxn{\footerr \pard Foot two\par}\pard Second{\super\chftn}{\footnote \pard\plain {\super\chftn} Note text\sect\par} {\*\atrfstart 3}section{\*\atrfend 3}{\*\atnid AB}{\*\atnauthor Ann}\chatn{\*\annotation{\*\atnref 3}{\*\atndate 1000}\pard Remark\par}{\revised\revauth1\revdttm-1480361314 added}.\par}";
|
||||||
|
let d = import("rtf", rtf).unwrap().unwrap();
|
||||||
|
assert_eq!(d.sections().len(), 2);
|
||||||
|
let check = |d: &Document| {
|
||||||
|
let (notes, comments) = rtf_notes(d);
|
||||||
|
assert_eq!(notes.len(), 1, "{notes:?}");
|
||||||
|
assert!(notes[0].contains("Note text"), "{notes:?}");
|
||||||
|
assert_eq!(comments.len(), 1, "{comments:?}");
|
||||||
|
assert!(comments[0].contains("Remark"), "{comments:?}");
|
||||||
|
let ins = d.body.iter().filter_map(|b| b.as_para()).any(|p| p.plain_text().contains("added"));
|
||||||
|
assert!(ins);
|
||||||
|
};
|
||||||
|
check(&d);
|
||||||
|
let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap();
|
||||||
|
assert!(out.contains("\\revised") && out.contains("\\footnote") && out.contains("\\atrfstart"), "{out}");
|
||||||
|
let d2 = import("rtf", out.as_bytes()).unwrap().unwrap();
|
||||||
|
check(&d2);
|
||||||
|
}
|
||||||
|
|
||||||
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
|
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
|
||||||
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
|
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -194,6 +194,87 @@ pub fn decode(bytes: &[u8], budget: u64) -> Result<DynamicImage, Refusal> {
|
|||||||
probe(bytes)?.decode(budget)
|
probe(bytes)?.decode(budget)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why a picture can't be drawn, as strict export reports it (#6005).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DrawFailure {
|
||||||
|
/// A picture format we know, but it doesn't decode (damaged, truncated, too large).
|
||||||
|
Undecodable,
|
||||||
|
/// Bytes that are not a picture format we draw.
|
||||||
|
UnsupportedFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrawFailure {
|
||||||
|
/// The words reports use.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DrawFailure::Undecodable => "undecodable",
|
||||||
|
DrawFailure::UnsupportedFormat => "unsupported format",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Refusal> for DrawFailure {
|
||||||
|
fn from(r: &Refusal) -> Self {
|
||||||
|
match r {
|
||||||
|
Refusal::NotAPicture | Refusal::Unsupported(_) => DrawFailure::UnsupportedFormat,
|
||||||
|
Refusal::TooLarge { .. } | Refusal::Broken(_) => DrawFailure::Undecodable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a raster picture for drawing: the one decision the PDF export, the raster renderer and
|
||||||
|
/// strict export's check all take. The decoder is held to `max_side` pixels a side and the header
|
||||||
|
/// is charged against `budget` bytes before anything is allocated. A picture with no pixels or a
|
||||||
|
/// side past `max_side` is refused.
|
||||||
|
pub fn decode_for_drawing(bytes: &[u8], budget: u64, max_side: u32) -> Result<DynamicImage, Refusal> {
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_image_width = Some(max_side);
|
||||||
|
limits.max_image_height = Some(max_side);
|
||||||
|
let reader = || {
|
||||||
|
let mut r = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Refusal::NotAPicture)?;
|
||||||
|
r.limits(limits.clone());
|
||||||
|
Ok::<_, Refusal>(r)
|
||||||
|
};
|
||||||
|
// Header-only size check before anything is allocated: the decoder's buffer at its own pixel
|
||||||
|
// size (8 bytes for 16-bit RGBA) and the RGBA conversion are charged.
|
||||||
|
let (hw, hh, native) = {
|
||||||
|
let d = reader()?.into_decoder().map_err(broken)?;
|
||||||
|
let (w, h) = d.dimensions();
|
||||||
|
(w, h, u64::from(d.color_type().bytes_per_pixel()))
|
||||||
|
};
|
||||||
|
if !crate::decode_peak_fits(hw, hh, native, budget) {
|
||||||
|
return Err(Refusal::TooLarge { width: hw, height: hh });
|
||||||
|
}
|
||||||
|
let img = reader()?.decode().map_err(broken)?;
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
if w == 0 || h == 0 || w > max_side || h > max_side {
|
||||||
|
return Err(Refusal::Broken("it has no pixels or a side past the limit".into()));
|
||||||
|
}
|
||||||
|
Ok(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largest page-background picture the page raster draws, in pixels (hostile files).
|
||||||
|
pub const MAX_BACKGROUND_PIXELS: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// The pixel size of a page-background picture the page raster draws (Design > Page Color > Fill
|
||||||
|
/// Effects), or None when it leaves the background out: not a raster picture it can read (a
|
||||||
|
/// metafile is not) or one with no pixels or more than [`MAX_BACKGROUND_PIXELS`]. The raster and
|
||||||
|
/// the strict check both ask this, so they can't drift.
|
||||||
|
pub fn background_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||||
|
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||||
|
(w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_BACKGROUND_PIXELS).then_some((w, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a raster picture decodes for drawing, by the rule the exporters use ([`probe`] within
|
||||||
|
/// [`crate::MAX_DECODED_BYTES`], then [`decode_for_drawing`] with `max_side`).
|
||||||
|
pub fn check_for_drawing(bytes: &[u8], max_side: u32) -> Result<(), DrawFailure> {
|
||||||
|
let fail = |r: Refusal| DrawFailure::from(&r);
|
||||||
|
if !probe(bytes).map_err(fail)?.fits(crate::MAX_DECODED_BYTES) {
|
||||||
|
return Err(DrawFailure::Undecodable);
|
||||||
|
}
|
||||||
|
decode_for_drawing(bytes, crate::MAX_DECODED_BYTES, max_side).map(|_| ()).map_err(fail)
|
||||||
|
}
|
||||||
|
|
||||||
/// Bytes decoding a `width` × `height` picture holds at its peak: the decoder's output (`native`
|
/// Bytes decoding a `width` × `height` picture holds at its peak: the decoder's output (`native`
|
||||||
/// bytes a pixel) beside what the decoder keeps while it decodes (`held`), then the output beside
|
/// bytes a pixel) beside what the decoder keeps while it decodes (`held`), then the output beside
|
||||||
/// its RGBA8 conversion, then that beside a premultiplied copy. None on overflow.
|
/// its RGBA8 conversion, then that beside a premultiplied copy. None on overflow.
|
||||||
@@ -340,6 +421,18 @@ mod tests {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drawing_check_tells_unsupported_from_undecodable() {
|
||||||
|
let png = encode(image::RgbaImage::new(4, 4), ImageFormat::Png);
|
||||||
|
assert_eq!(check_for_drawing(&png, 100), Ok(()));
|
||||||
|
assert_eq!(check_for_drawing(b"junk", 100), Err(DrawFailure::UnsupportedFormat));
|
||||||
|
assert_eq!(check_for_drawing(&png[..png.len() - 20], 100), Err(DrawFailure::Undecodable));
|
||||||
|
// A side past the limit is refused, by the same function the exporters draw with.
|
||||||
|
assert_eq!(check_for_drawing(&png, 3), Err(DrawFailure::Undecodable));
|
||||||
|
assert!(decode_for_drawing(&png, crate::MAX_DECODED_BYTES, 100).is_ok());
|
||||||
|
assert_eq!(DrawFailure::Undecodable.label(), "undecodable");
|
||||||
|
}
|
||||||
|
|
||||||
/// A PNG whose header declares `w` × `h` pixels, with no pixel data.
|
/// A PNG whose header declares `w` × `h` pixels, with no pixel data.
|
||||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||||
let mut png = encode(image::RgbaImage::new(1, 1), ImageFormat::Png);
|
let mut png = encode(image::RgbaImage::new(1, 1), ImageFormat::Png);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ log = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||||
|
wordcraft-fonts = { workspace = true, features = ["test-support"] }
|
||||||
|
|
||||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||||
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
|
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn occupied_follows_the_outline() {
|
fn occupied_follows_the_outline() {
|
||||||
|
crate::test_fonts();
|
||||||
let c = diamond(false);
|
let c = diamond(false);
|
||||||
let s = c.occupied(0.0, 10.0);
|
let s = c.occupied(0.0, 10.0);
|
||||||
assert_eq!(s.len(), 1);
|
assert_eq!(s.len(), 1);
|
||||||
@@ -363,6 +364,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn through_keeps_interior_gaps() {
|
fn through_keeps_interior_gaps() {
|
||||||
|
crate::test_fonts();
|
||||||
// A U: two prongs joined at the bottom.
|
// A U: two prongs joined at the bottom.
|
||||||
let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
|
let u = [(0.0, 0.0), (20.0, 0.0), (20.0, 80.0), (80.0, 80.0), (80.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
|
||||||
let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap();
|
let (_, tight) = Contour::new(&u, 0.0, 0.0, 0.0, false).unwrap();
|
||||||
@@ -374,6 +376,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pictures_wrap_around_their_opaque_pixels() {
|
fn pictures_wrap_around_their_opaque_pixels() {
|
||||||
|
crate::test_fonts();
|
||||||
let png = |img: image::DynamicImage| {
|
let png = |img: image::DynamicImage| {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||||
@@ -429,6 +432,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hostile_points_are_dropped() {
|
fn hostile_points_are_dropped() {
|
||||||
|
crate::test_fonts();
|
||||||
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
|
assert!(Contour::new(&[(f32::NAN, 0.0), (1.0, 1.0), (f32::INFINITY, 2.0)], 0.0, 0.0, 0.0, false).is_none());
|
||||||
let c = diamond(true);
|
let c = diamond(true);
|
||||||
assert!(c.occupied(f32::NAN, 1.0).is_empty());
|
assert!(c.occupied(f32::NAN, 1.0).is_empty());
|
||||||
|
|||||||
@@ -815,6 +815,8 @@ pub fn section_break_label(start: SectionStart) -> &'static str {
|
|||||||
|
|
||||||
/// Width of a [`Draw::MarkText`] label.
|
/// Width of a [`Draw::MarkText`] label.
|
||||||
pub fn mark_text_width(text: &str, size: f32) -> f32 {
|
pub fn mark_text_width(text: &str, size: f32) -> f32 {
|
||||||
|
#[cfg(test)]
|
||||||
|
crate::test_fonts();
|
||||||
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
let face = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||||
let k = size / face.upem.max(1.0) as f32;
|
let k = size / face.upem.max(1.0) as f32;
|
||||||
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
|
text.chars().map(|c| face.advance(face.glyph_for(c)) as f32 * k).sum()
|
||||||
@@ -846,7 +848,7 @@ fn section_break_mark(start: SectionStart, x0: f32, x1: f32, base: f32, size: f3
|
|||||||
fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec<Draw>) {
|
fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &mut Vec<Draw>) {
|
||||||
for it in items {
|
for it in items {
|
||||||
match it {
|
match it {
|
||||||
MItem::Glyphs { face, size, color, synth_bold, synth_italic, glyphs, text } => out.push(Draw::Glyphs {
|
MItem::Glyphs { face, size, color, synth_bold, synth_italic, glyphs, text, ranges } => out.push(Draw::Glyphs {
|
||||||
face: *face,
|
face: *face,
|
||||||
size: *size,
|
size: *size,
|
||||||
glyphs: glyphs.iter().map(|(g, gx, gy)| (*g, x + gx, base - gy)).collect(),
|
glyphs: glyphs.iter().map(|(g, gx, gy)| (*g, x + gx, base - gy)).collect(),
|
||||||
@@ -856,8 +858,7 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: &
|
|||||||
synth_italic: *synth_italic,
|
synth_italic: *synth_italic,
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
link: None,
|
link: None,
|
||||||
// Equation text maps back through the font (no per-glyph ranges from the math layout).
|
ranges: ranges.clone(),
|
||||||
ranges: Vec::new(),
|
|
||||||
text_bg: None,
|
text_bg: None,
|
||||||
x_scale: 1.0,
|
x_scale: 1.0,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) ->
|
|||||||
let mut out = Vec::with_capacity(runs.len());
|
let mut out = Vec::with_capacity(runs.len());
|
||||||
for (face, s, shaped) in runs {
|
for (face, s, shaped) in runs {
|
||||||
let k = scale(face);
|
let k = scale(face);
|
||||||
|
let ranges = crate::missing::cluster_ranges(s, &shaped);
|
||||||
let mut glyphs = Vec::with_capacity(shaped.len());
|
let mut glyphs = Vec::with_capacity(shaped.len());
|
||||||
for g in &shaped {
|
for g in &shaped {
|
||||||
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
|
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
|
||||||
@@ -324,7 +325,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) ->
|
|||||||
synth_italic: false,
|
synth_italic: false,
|
||||||
text: s.to_string(),
|
text: s.to_string(),
|
||||||
link: None,
|
link: None,
|
||||||
ranges: Vec::new(),
|
ranges,
|
||||||
text_bg: None,
|
text_bg: None,
|
||||||
x_scale: 1.0,
|
x_scale: 1.0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ref_link_switches() {
|
fn ref_link_switches() {
|
||||||
|
crate::test_fonts();
|
||||||
for instr in [r"REF mark\w\h", r#" REF "mark" \h"#, "REF\tmark \\H"] {
|
for instr in [r"REF mark\w\h", r#" REF "mark" \h"#, "REF\tmark \\H"] {
|
||||||
assert_eq!(reference_link(instr).as_deref(), Some("#mark"), "{instr}");
|
assert_eq!(reference_link(instr).as_deref(), Some("#mark"), "{instr}");
|
||||||
}
|
}
|
||||||
@@ -386,6 +387,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_fields() {
|
fn page_fields() {
|
||||||
|
crate::test_fonts();
|
||||||
let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() };
|
let ctx = FieldCtx { page: 4, pages: 12, ..Default::default() };
|
||||||
assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true));
|
assert_eq!(field_text("PAGE", "", &ctx), ("4".into(), true));
|
||||||
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
|
assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV");
|
||||||
@@ -480,6 +482,7 @@ mod tests {
|
|||||||
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
|
/// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored.
|
||||||
#[test]
|
#[test]
|
||||||
fn ask_and_set_show_nothing() {
|
fn ask_and_set_show_nothing() {
|
||||||
|
crate::test_fonts();
|
||||||
let ctx = FieldCtx::default();
|
let ctx = FieldCtx::default();
|
||||||
assert_eq!(field_text(r#" ASK Nominativo "Inserire nome" \d "«NOME»" "#, "«NOME» «COGNOME»", &ctx), (String::new(), false));
|
assert_eq!(field_text(r#" ASK Nominativo "Inserire nome" \d "«NOME»" "#, "«NOME» «COGNOME»", &ctx), (String::new(), false));
|
||||||
assert_eq!(field_text(r#"SET Codice "COD""#, "COD", &ctx), (String::new(), false));
|
assert_eq!(field_text(r#"SET Codice "COD""#, "COD", &ctx), (String::new(), false));
|
||||||
@@ -490,6 +493,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_references_show_their_bookmarks_page() {
|
fn page_references_show_their_bookmarks_page() {
|
||||||
|
crate::test_fonts();
|
||||||
let pages = HashMap::from([
|
let pages = HashMap::from([
|
||||||
("End_1".to_string(), (3, NumFormat::Decimal)),
|
("End_1".to_string(), (3, NumFormat::Decimal)),
|
||||||
("Intro".to_string(), (4, NumFormat::LowerRoman)),
|
("Intro".to_string(), (4, NumFormat::LowerRoman)),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user