mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-15 02:16:44 -06:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cde91a869 | ||
|
|
76e5732355 | ||
|
|
323cf78232 | ||
|
|
42a5df5f94 | ||
|
|
f8c66d0692 | ||
|
|
aba890e8a6 | ||
|
|
0cc92c9744 | ||
|
|
b02baeba43 | ||
|
|
3c77297b83 | ||
|
|
60415e055c | ||
|
|
0db15a1d5e | ||
|
|
33afe69094 | ||
|
|
669d5df0b1 | ||
|
|
d1a36fd0c6 | ||
|
|
beae4b82fa | ||
|
|
514085ca44 | ||
|
|
953f43baed | ||
|
|
acf5eb8727 | ||
|
|
079f478bd8 | ||
|
|
7296cf64de | ||
|
|
5574b32600 | ||
|
|
6272c9a4da | ||
|
|
172e56f12e | ||
|
|
b66122f718 | ||
|
|
4e1b6a71ab | ||
|
|
b2ec73926f | ||
|
|
7f90fd0bf1 | ||
|
|
4388fba5b2 | ||
|
|
c341669dbd | ||
|
|
a4d28e2586 |
Generated
+356
-118
@@ -155,13 +155,28 @@ version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-no-stdlib 2.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -210,15 +225,6 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.7.0"
|
||||
@@ -523,6 +529,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -614,9 +626,9 @@ version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
"alloc-no-stdlib 2.0.4",
|
||||
"alloc-stdlib 0.2.4",
|
||||
"brotli-decompressor 5.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -625,8 +637,18 @@ version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"alloc-no-stdlib 2.0.4",
|
||||
"alloc-stdlib 0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "6.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib 3.0.0",
|
||||
"alloc-stdlib 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1022,17 +1044,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-cfb"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
|
||||
[[package]]
|
||||
name = "craft-codepage"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
|
||||
[[package]]
|
||||
name = "craft-doc-binary"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-cfb",
|
||||
@@ -1048,7 +1070,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docx"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
@@ -1068,7 +1090,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-drawingml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"kurbo",
|
||||
@@ -1079,7 +1101,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-flow"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-wordmodel",
|
||||
"image",
|
||||
@@ -1122,6 +1144,19 @@ name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-html"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-codepage",
|
||||
"craft-flow",
|
||||
"craft-math",
|
||||
"craft-mime",
|
||||
"craft-wordmodel",
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
@@ -1145,19 +1180,83 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-image-codecs"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-jpeg",
|
||||
"craft-metadata",
|
||||
"exr",
|
||||
"flate2",
|
||||
"half",
|
||||
"image",
|
||||
"image-webp",
|
||||
"jpeg-encoder",
|
||||
"png",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-jpeg"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-latex"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-flow",
|
||||
"craft-math",
|
||||
"craft-mime",
|
||||
"craft-wordmodel",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-markdown"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-flow",
|
||||
"craft-html",
|
||||
"craft-wordmodel",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-math"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-metadata"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"brotli-decompressor 6.3.1",
|
||||
"chrono",
|
||||
"craft-tiff",
|
||||
"quick-xml 0.41.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-metafile"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"image",
|
||||
@@ -1165,10 +1264,58 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-mime"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"chrono",
|
||||
"craft-codepage",
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-odf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"craft-flow",
|
||||
"craft-math",
|
||||
"craft-metafile",
|
||||
"craft-wordmodel",
|
||||
"craft-xml",
|
||||
"craft-zip",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ods"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-odf",
|
||||
"craft-sheet",
|
||||
"craft-xml",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-offcrypto"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -1185,7 +1332,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-opc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"craft-zip",
|
||||
@@ -1230,7 +1377,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-rtf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-codepage",
|
||||
"craft-flow",
|
||||
@@ -1240,6 +1387,20 @@ dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-sheet"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
|
||||
[[package]]
|
||||
name = "craft-tiff"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1284,7 +1445,7 @@ dependencies = [
|
||||
"verifysign",
|
||||
"winreg",
|
||||
"winsafe",
|
||||
"zip 9.1.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1329,7 +1490,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-wordmodel"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"craft-math",
|
||||
@@ -1340,10 +1501,35 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xls"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-cfb",
|
||||
"craft-sheet",
|
||||
"md-5",
|
||||
"miniz_oxide 0.9.1",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xlsx"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"craft-drawingml",
|
||||
"craft-opc",
|
||||
"craft-sheet",
|
||||
"craft-xml",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1352,11 +1538,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-zip"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75c70fa6a786240eb853e6267e25fc445f510cd0#75c70fa6a786240eb853e6267e25fc445f510cd0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=41fb227542efb8a7748caa1c659f271d8605303c#41fb227542efb8a7748caa1c659f271d8605303c"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"zip 9.1.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1484,17 +1670,6 @@ version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
@@ -1906,6 +2081,22 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb29d0be702adf92c645d9b2f93472906ba83d400e59f98adb3770ce5c18c2f6"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
"half",
|
||||
"lebe",
|
||||
"miniz_oxide 0.8.9",
|
||||
"multiversion",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -2954,6 +3145,7 @@ dependencies = [
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"qoi",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
@@ -3102,6 +3294,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpeg-encoder"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
@@ -3185,6 +3383,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
@@ -3273,6 +3477,16 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -3344,6 +3558,28 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb8a68be21d0755f83bfe47e79b896cc732687cf1903f0e50585e8d6ecc53076"
|
||||
dependencies = [
|
||||
"multiversion-macros",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion-macros"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a05e0c3c3e4edaa21d87c645852ed0b0c3663752bcc6ec43353bd6189dce35e1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"target-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -4287,6 +4523,15 @@ version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -5564,6 +5809,12 @@ dependencies = [
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-features"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -6968,7 +7219,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -7008,27 +7259,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"craft-zip",
|
||||
"image",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-mcp",
|
||||
"wordcraft-render",
|
||||
"wordcraft-zotero",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -7042,7 +7294,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"craft-doc-binary",
|
||||
@@ -7054,32 +7306,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"cfb",
|
||||
"craft-docx",
|
||||
"craft-wordmodel",
|
||||
"craft-xml",
|
||||
"craft-zip",
|
||||
"image",
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-font-catalog",
|
||||
"craft-font-store",
|
||||
"craft-proof",
|
||||
"craft-zip",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
@@ -7102,14 +7355,13 @@ dependencies = [
|
||||
"wordcraft-pdf",
|
||||
"wordcraft-render",
|
||||
"zeroize",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"brotli-decompressor 5.0.3",
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
"log",
|
||||
@@ -7121,45 +7373,48 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
"cfb",
|
||||
"chardetng",
|
||||
"chrono",
|
||||
"craft-flow",
|
||||
"craft-html",
|
||||
"craft-image-codecs",
|
||||
"craft-latex",
|
||||
"craft-markdown",
|
||||
"craft-math",
|
||||
"craft-metafile",
|
||||
"craft-mime",
|
||||
"craft-odf",
|
||||
"craft-ods",
|
||||
"craft-rtf",
|
||||
"craft-sheet",
|
||||
"craft-wordmodel",
|
||||
"craft-xls",
|
||||
"craft-xlsx",
|
||||
"craft-zip",
|
||||
"encoding_rs",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"image",
|
||||
"js-sys",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-docx",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"zeroize",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"craft-image-codecs",
|
||||
"image",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"craft-metafile",
|
||||
"craft-proof",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -7173,12 +7428,11 @@ dependencies = [
|
||||
"wordcraft-doc",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7188,19 +7442,12 @@ dependencies = [
|
||||
"wordcraft-render",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-metafile",
|
||||
"craft-pdf",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
@@ -7214,13 +7461,13 @@ dependencies = [
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-layout",
|
||||
"wordcraft-metafile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"craft-metafile",
|
||||
"image",
|
||||
"log",
|
||||
"vello_cpu",
|
||||
@@ -7228,12 +7475,11 @@ dependencies = [
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-layout",
|
||||
"wordcraft-metafile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7242,6 +7488,7 @@ dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"craft-zip",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
@@ -7261,12 +7508,11 @@ dependencies = [
|
||||
"wordcraft-render",
|
||||
"wordcraft-zotero",
|
||||
"zbus",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -7287,7 +7533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7333,7 +7579,7 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "200a7b806377b65f1ff9a985874332f15357c87e5dd7855f35f06dade95b8b3d"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"brotli-decompressor 5.0.3",
|
||||
"bytes",
|
||||
"flate2",
|
||||
]
|
||||
@@ -7450,7 +7696,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7693,20 +7939,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
@@ -7735,24 +7967,21 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
@@ -7762,6 +7991,15 @@ dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpegxl"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
|
||||
+21
-9
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.1"
|
||||
version = "0.19.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -36,7 +36,7 @@ wordcraft-mcp = { path = "crates/mcp" }
|
||||
wordcraft-zotero = { path = "crates/zotero" }
|
||||
wordcraft-control-key = { path = "crates/control-key" }
|
||||
wordcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
wordcraft-metafile = { path = "crates/metafile" }
|
||||
craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -66,16 +66,28 @@ wuff = "0.2.9"
|
||||
# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT.
|
||||
brotli-decompressor = "5"
|
||||
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 }
|
||||
craft-image-codecs = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" }
|
||||
craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" }
|
||||
craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0", features = ["doc"] }
|
||||
craft-doc-binary = { git = "https://github.com/storytold/craft-libs", rev = "75c70fa6a786240eb853e6267e25fc445f510cd0", version = "=0.1.0" }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
craft-wordmodel = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-docx = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["doc"] }
|
||||
craft-doc-binary = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-flow = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-odf = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["text"] }
|
||||
craft-markdown = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-html = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["flow"] }
|
||||
craft-latex = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-math = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-mime = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0", features = ["detect"] }
|
||||
craft-sheet = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-ods = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
craft-zip = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.9"
|
||||
cfb = "0.15"
|
||||
|
||||
@@ -1445,3 +1445,7 @@ MATH glyphs that reference the same construction now share its immutable variant
|
||||
> 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements.
|
||||
| 2026-11-03 | trivial | Progress log: a line breaks after a hyphen before a slash (#3408) |
|
||||
| 2026-11-12 | minor | Progress log: layout.fitOnePage condenses a multi-page document by one page while preserving typography hierarchy (#6433) |
|
||||
|
||||
### Format Painter integration refresh, 2026-10-30
|
||||
|
||||
Single-click release applies the armed painter to the target word without trailing spaces and disarms it. Explicit selections retain their scope (#1455, partial). Source and UI regressions are retained after the canvas/input module split. Earlier Windows pointer and browser qualification are historical; current-head hosted validation is pending, and native shortcut/Mint qualification is not newly claimed.
|
||||
|
||||
@@ -24,5 +24,6 @@ sys-locale = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
craft-zip = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! `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};
|
||||
|
||||
@@ -64,11 +63,11 @@ fn uncovered() -> char {
|
||||
}
|
||||
|
||||
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
|
||||
wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn pic(rid: &str) -> String {
|
||||
@@ -106,7 +105,7 @@ fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<
|
||||
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 zip = craft_zip::Writer::new();
|
||||
let mut parts: Vec<(&str, &[u8])> = vec![
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
@@ -115,10 +114,9 @@ fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<
|
||||
];
|
||||
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.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -372,7 +370,7 @@ fn run_with_strict_cannot_forge_an_error_line_either() {
|
||||
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("\\nwordcraft-cli: strict: forged") && text.contains("\\u{202e}"), "{text}");
|
||||
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
|
||||
assert!(!out.exists());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1e515422afdda2f317af430c1f9d68a5f83935b4
|
||||
e56d26244bbac099bfd42e23fb12f78a4be418a7
|
||||
|
||||
@@ -12,16 +12,16 @@ workspace = true
|
||||
craft-docx = { workspace = true }
|
||||
craft-wordmodel = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
craft-xml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
base64 = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
log = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
craft-zip = { workspace = true }
|
||||
|
||||
+7
-29
@@ -19,40 +19,18 @@ pub(crate) const KIND: &str = "wordcraft-fit-v1";
|
||||
/// Serialize `fit` as the custom XML store.
|
||||
pub(crate) fn write(fit: &FitState) -> Result<Vec<u8>, DocxError> {
|
||||
let json = serde_json::to_string(fit).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let esc_json = quick_xml::escape::escape(&json);
|
||||
Ok(format!(r#"<?xml version="1.0" encoding="UTF-8"?><{ROOT} xmlns="{NS}" kind="{KIND}"><fitState>{}</fitState></{ROOT}>"#, esc_json)
|
||||
.into_bytes())
|
||||
let esc_json = craft_xml::esc(&json);
|
||||
Ok(format!(r#"<?xml version="1.0" encoding="UTF-8"?><{ROOT} xmlns="{NS}" kind="{KIND}"><fitState>{}</fitState></{ROOT}>"#, esc_json).into_bytes())
|
||||
}
|
||||
|
||||
/// Read the state from a custom XML part's bytes, if it is ours.
|
||||
pub(crate) fn read_from_bytes(bytes: &[u8]) -> Option<FitState> {
|
||||
let mut reader = quick_xml::Reader::from_reader(bytes);
|
||||
let mut buf = Vec::new();
|
||||
let mut is_our_kind = false;
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(quick_xml::events::Event::Start(ref e)) => {
|
||||
let name = e.local_name();
|
||||
if name.as_ref() == ROOT.as_bytes() {
|
||||
for attr in e.attributes().flatten() {
|
||||
if attr.key.local_name().as_ref() == b"kind" && attr.value.as_ref() == KIND.as_bytes() {
|
||||
is_our_kind = true;
|
||||
}
|
||||
}
|
||||
} else if name.as_ref() == b"fitState" && is_our_kind {
|
||||
let text = reader.read_text(e.to_end().name()).ok()?;
|
||||
let s = std::str::from_utf8(text.as_ref()).ok()?;
|
||||
let unescaped = quick_xml::escape::unescape(s).ok()?;
|
||||
return serde_json::from_str(&unescaped).ok();
|
||||
}
|
||||
}
|
||||
Ok(quick_xml::events::Event::Eof) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
let root = craft_xml::parse(bytes).ok()?;
|
||||
if root.local() != ROOT || root.attr("kind") != Some(KIND) {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
let text = root.child_text("fitState")?;
|
||||
serde_json::from_str(text.as_ref()).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+25
-30
@@ -8,10 +8,9 @@ mod fit;
|
||||
mod model_bridge;
|
||||
|
||||
pub use craft_docx::{
|
||||
DEFAULT_SPIN_COUNT, DocxError, EmbeddedFont, Flavor, MAX_PASSWORD_CHARS, MAX_SOURCES,
|
||||
MAX_XML_FILE, PASSWORD_REQUIRED, PasswordCheck, WRONG_PASSWORD, XmlKind, check_password,
|
||||
check_protection_password, encrypt, encrypt_standard_for_tests,
|
||||
encrypt_with_spin_count, package_title, protection_hash, sniff_xml,
|
||||
DEFAULT_SPIN_COUNT, DocxError, EmbeddedFont, Flavor, MAX_PASSWORD_CHARS, MAX_SOURCES, MAX_XML_FILE, PASSWORD_REQUIRED, PasswordCheck,
|
||||
WRONG_PASSWORD, XmlKind, check_password, check_protection_password, encrypt, encrypt_standard_for_tests, encrypt_with_spin_count, package_title,
|
||||
protection_hash, sniff_xml,
|
||||
};
|
||||
|
||||
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
@@ -56,19 +55,13 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcr
|
||||
pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
let bridge_importer = |shared_chunk: &craft_docx::AltChunk| -> Option<craft_wordmodel::Document> {
|
||||
let importer = opts.chunks?;
|
||||
let chunk = AltChunk {
|
||||
content_type: shared_chunk.content_type,
|
||||
part: shared_chunk.part,
|
||||
bytes: shared_chunk.bytes,
|
||||
};
|
||||
let chunk = AltChunk { content_type: shared_chunk.content_type, part: shared_chunk.part, bytes: shared_chunk.bytes };
|
||||
let doc = importer(&chunk)?;
|
||||
to_shared_model(&doc).ok()
|
||||
};
|
||||
|
||||
let shared_opts = craft_docx::ReadOptions {
|
||||
password: opts.password,
|
||||
chunks: opts.chunks.as_ref().map(|_| &bridge_importer as craft_docx::ChunkImporter),
|
||||
};
|
||||
let shared_opts =
|
||||
craft_docx::ReadOptions { password: opts.password, chunks: opts.chunks.as_ref().map(|_| &bridge_importer as craft_docx::ChunkImporter) };
|
||||
|
||||
let shared = craft_docx::read_with(bytes, &shared_opts)?;
|
||||
from_shared_model(shared)
|
||||
@@ -105,21 +98,22 @@ pub fn write_wordml2003(doc: &wordcraft_doc::Document) -> Result<Vec<u8>, DocxEr
|
||||
}
|
||||
|
||||
/// Draw a chart.
|
||||
pub fn chart_items(
|
||||
spec: &wordcraft_doc::chart::ChartSpec,
|
||||
theme: &[wordcraft_doc::Rgb],
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> Vec<wordcraft_doc::graphic::GraphicItem> {
|
||||
let shared_spec: craft_wordmodel::chart::ChartSpec = model_bridge::convert_ref(spec).expect("chart spec convert");
|
||||
let shared_theme: Vec<craft_wordmodel::props::Rgb> = model_bridge::convert_ref(theme).expect("theme convert");
|
||||
pub fn chart_items(spec: &wordcraft_doc::chart::ChartSpec, theme: &[wordcraft_doc::Rgb], w: f32, h: f32) -> Vec<wordcraft_doc::graphic::GraphicItem> {
|
||||
let (Ok(shared_spec), Ok(shared_theme)) = (
|
||||
model_bridge::convert_ref::<_, craft_wordmodel::chart::ChartSpec>(spec),
|
||||
model_bridge::convert_ref::<_, Vec<craft_wordmodel::props::Rgb>>(theme),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let shared_items = craft_docx::chart_items(&shared_spec, &shared_theme, w, h);
|
||||
model_bridge::convert(shared_items).expect("chart items convert")
|
||||
model_bridge::convert(shared_items).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Write a chart part.
|
||||
pub fn chart_xml(spec: &wordcraft_doc::chart::ChartSpec) -> Vec<u8> {
|
||||
let shared_spec: craft_wordmodel::chart::ChartSpec = model_bridge::convert_ref(spec).expect("chart spec convert");
|
||||
let Ok(shared_spec) = model_bridge::convert_ref(spec) else {
|
||||
return Vec::new();
|
||||
};
|
||||
craft_docx::chart_xml(&shared_spec).into_bytes()
|
||||
}
|
||||
|
||||
@@ -130,19 +124,20 @@ pub fn smart_art_items(
|
||||
w: f32,
|
||||
h: f32,
|
||||
) -> Vec<wordcraft_doc::graphic::GraphicItem> {
|
||||
let shared_spec: craft_wordmodel::smart_art::SmartArtSpec = model_bridge::convert_ref(spec).expect("smart art spec convert");
|
||||
let shared_theme: Vec<craft_wordmodel::props::Rgb> = model_bridge::convert_ref(theme).expect("theme convert");
|
||||
let (Ok(shared_spec), Ok(shared_theme)) = (
|
||||
model_bridge::convert_ref::<_, craft_wordmodel::smart_art::SmartArtSpec>(spec),
|
||||
model_bridge::convert_ref::<_, Vec<craft_wordmodel::props::Rgb>>(theme),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let shared_items = craft_docx::smart_art_items(&shared_spec, &shared_theme, w, h);
|
||||
model_bridge::convert(shared_items).expect("smart art items convert")
|
||||
model_bridge::convert(shared_items).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read bibliography sources from a `b:Sources` file.
|
||||
pub fn read_sources(bytes: &[u8]) -> Result<Sources, DocxError> {
|
||||
let shared = craft_docx::read_sources(bytes)?;
|
||||
Ok(Sources {
|
||||
sources: model_bridge::convert(shared.sources).map_err(|e| DocxError::Xml(e.to_string()))?,
|
||||
style: shared.style,
|
||||
})
|
||||
Ok(Sources { sources: model_bridge::convert(shared.sources).map_err(|e| DocxError::Xml(e.to_string()))?, style: shared.style })
|
||||
}
|
||||
|
||||
/// The font files embedded in the document's font table.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Bounded conversion between craft-wordmodel and WordCraft's document model.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_docx::DocxError;
|
||||
@@ -29,24 +30,29 @@ where
|
||||
// From Shared Model -> WordCraft Document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn blocks_from_shared(source: craft_wordmodel::Blocks) -> Result<wordcraft_doc::Blocks, DocxError> {
|
||||
type GraphicCache = HashMap<usize, Arc<wordcraft_doc::graphic::Graphic>>;
|
||||
|
||||
fn blocks_from_shared(source: craft_wordmodel::Blocks, graphics: &mut GraphicCache) -> Result<wordcraft_doc::Blocks, DocxError> {
|
||||
source
|
||||
.into_iter()
|
||||
.map(|block| {
|
||||
let block = Arc::try_unwrap(block).unwrap_or_else(|shared| (*shared).clone());
|
||||
block_value_from_shared(block).map(Arc::new)
|
||||
block_value_from_shared(block, graphics).map(Arc::new)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn block_value_from_shared(source: craft_wordmodel::Block) -> Result<wordcraft_doc::Block, DocxError> {
|
||||
fn block_value_from_shared(source: craft_wordmodel::Block, graphics: &mut GraphicCache) -> Result<wordcraft_doc::Block, DocxError> {
|
||||
match source {
|
||||
craft_wordmodel::Block::Para(paragraph) => paragraph_value_from_shared(paragraph).map(wordcraft_doc::Block::Para),
|
||||
craft_wordmodel::Block::Table(table) => table_value_from_shared(table).map(wordcraft_doc::Block::Table),
|
||||
craft_wordmodel::Block::Para(paragraph) => paragraph_value_from_shared(paragraph, graphics).map(wordcraft_doc::Block::Para),
|
||||
craft_wordmodel::Block::Table(table) => table_value_from_shared(table, graphics).map(wordcraft_doc::Block::Table),
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_value_from_shared(source: craft_wordmodel::para::Paragraph) -> Result<wordcraft_doc::para::Paragraph, DocxError> {
|
||||
fn paragraph_value_from_shared(
|
||||
source: craft_wordmodel::para::Paragraph,
|
||||
graphics: &mut GraphicCache,
|
||||
) -> Result<wordcraft_doc::para::Paragraph, DocxError> {
|
||||
let craft_wordmodel::para::Paragraph { text, runs, props, mark, objects, section, .. } = source;
|
||||
let mut paragraph = wordcraft_doc::para::Paragraph::new();
|
||||
paragraph.text = text;
|
||||
@@ -54,26 +60,20 @@ fn paragraph_value_from_shared(source: craft_wordmodel::para::Paragraph) -> Resu
|
||||
runs.into_iter().map(|run| Ok(wordcraft_doc::para::Run { len: run.len, props: convert(run.props)? })).collect::<Result<_, DocxError>>()?;
|
||||
paragraph.props = convert(props)?;
|
||||
paragraph.mark = convert(mark)?;
|
||||
paragraph.objects = objects.into_iter().map(object_value_from_shared).collect::<Result<_, _>>()?;
|
||||
paragraph.objects = objects.into_iter().map(|o| object_value_from_shared(o, graphics)).collect::<Result<_, _>>()?;
|
||||
paragraph.section = section.map(|section| convert(*section).map(Box::new)).transpose()?;
|
||||
Ok(paragraph)
|
||||
}
|
||||
|
||||
fn object_value_from_shared(source: craft_wordmodel::para::InlineObject) -> Result<wordcraft_doc::para::InlineObject, DocxError> {
|
||||
fn object_value_from_shared(
|
||||
source: craft_wordmodel::para::InlineObject,
|
||||
graphics: &mut GraphicCache,
|
||||
) -> Result<wordcraft_doc::para::InlineObject, DocxError> {
|
||||
use craft_wordmodel::para::InlineObject as Source;
|
||||
use wordcraft_doc::para::InlineObject as Target;
|
||||
|
||||
match source {
|
||||
Source::Image {
|
||||
media,
|
||||
w,
|
||||
h,
|
||||
alt,
|
||||
float,
|
||||
crop,
|
||||
fx,
|
||||
ole,
|
||||
} => Ok(Target::Image {
|
||||
Source::Image { media, w, h, alt, float, crop, fx, ole } => Ok(Target::Image {
|
||||
media,
|
||||
w,
|
||||
h,
|
||||
@@ -83,49 +83,70 @@ fn object_value_from_shared(source: craft_wordmodel::para::InlineObject) -> Resu
|
||||
fx: convert(fx)?,
|
||||
ole: ole.map(|o| convert(Arc::try_unwrap(o).unwrap_or_else(|s| (*s).clone())).map(Arc::new)).transpose()?,
|
||||
}),
|
||||
Source::Graphic { w, h, alt, float, graphic } => {
|
||||
let key = Arc::as_ptr(&graphic) as usize;
|
||||
let graphic = if let Some(cached) = graphics.get(&key) {
|
||||
Arc::clone(cached)
|
||||
} else {
|
||||
let converted: Arc<wordcraft_doc::graphic::Graphic> = Arc::new(convert(Arc::try_unwrap(graphic).unwrap_or_else(|s| (*s).clone()))?);
|
||||
graphics.insert(key, Arc::clone(&converted));
|
||||
converted
|
||||
};
|
||||
Ok(Target::Graphic { w, h, alt, float: convert(float)?, graphic })
|
||||
}
|
||||
Source::Group { w, h, float, ch_w, ch_h, children, canvas } => Ok(Target::Group {
|
||||
w,
|
||||
h,
|
||||
float: convert(float)?,
|
||||
ch_w,
|
||||
ch_h,
|
||||
children: children.into_iter().map(group_child_value_from_shared).collect::<Result<_, _>>()?,
|
||||
children: children.into_iter().map(|c| group_child_value_from_shared(c, graphics)).collect::<Result<_, _>>()?,
|
||||
canvas: canvas.map(convert).transpose()?,
|
||||
}),
|
||||
other => convert(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn group_child_value_from_shared(source: craft_wordmodel::para::GroupChild) -> Result<wordcraft_doc::para::GroupChild, DocxError> {
|
||||
Ok(wordcraft_doc::para::GroupChild { x: source.x, y: source.y, obj: object_value_from_shared(source.obj)? })
|
||||
fn group_child_value_from_shared(
|
||||
source: craft_wordmodel::para::GroupChild,
|
||||
graphics: &mut GraphicCache,
|
||||
) -> Result<wordcraft_doc::para::GroupChild, DocxError> {
|
||||
Ok(wordcraft_doc::para::GroupChild { x: source.x, y: source.y, obj: object_value_from_shared(source.obj, graphics)? })
|
||||
}
|
||||
|
||||
fn table_value_from_shared(source: craft_wordmodel::table::Table) -> Result<wordcraft_doc::table::Table, DocxError> {
|
||||
fn table_value_from_shared(source: craft_wordmodel::table::Table, graphics: &mut GraphicCache) -> Result<wordcraft_doc::table::Table, DocxError> {
|
||||
Ok(wordcraft_doc::table::Table {
|
||||
props: convert(source.props)?,
|
||||
grid: source.grid,
|
||||
rows: source.rows.into_iter().map(row_value_from_shared).collect::<Result<_, _>>()?,
|
||||
rows: source.rows.into_iter().map(|r| row_value_from_shared(r, graphics)).collect::<Result<_, _>>()?,
|
||||
controls: convert(source.controls)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_value_from_shared(source: craft_wordmodel::table::Row) -> Result<wordcraft_doc::table::Row, DocxError> {
|
||||
fn row_value_from_shared(source: craft_wordmodel::table::Row, graphics: &mut GraphicCache) -> Result<wordcraft_doc::table::Row, DocxError> {
|
||||
Ok(wordcraft_doc::table::Row {
|
||||
props: convert(source.props)?,
|
||||
cells: source.cells.into_iter().map(cell_value_from_shared).collect::<Result<_, _>>()?,
|
||||
cells: source.cells.into_iter().map(|c| cell_value_from_shared(c, graphics)).collect::<Result<_, _>>()?,
|
||||
controls: convert(source.controls)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_value_from_shared(source: craft_wordmodel::table::Cell) -> Result<wordcraft_doc::table::Cell, DocxError> {
|
||||
Ok(wordcraft_doc::table::Cell { props: convert(source.props)?, blocks: blocks_from_shared(source.blocks)?, controls: convert(source.controls)? })
|
||||
fn cell_value_from_shared(source: craft_wordmodel::table::Cell, graphics: &mut GraphicCache) -> Result<wordcraft_doc::table::Cell, DocxError> {
|
||||
Ok(wordcraft_doc::table::Cell {
|
||||
props: convert(source.props)?,
|
||||
blocks: blocks_from_shared(source.blocks, graphics)?,
|
||||
controls: convert(source.controls)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn part_value_from_shared(source: craft_wordmodel::Part) -> Result<wordcraft_doc::Part, DocxError> {
|
||||
Ok(wordcraft_doc::Part { kind: convert(source.kind)?, blocks: blocks_from_shared(source.blocks)?, body: convert(source.body)? })
|
||||
fn part_value_from_shared(source: craft_wordmodel::Part, graphics: &mut GraphicCache) -> Result<wordcraft_doc::Part, DocxError> {
|
||||
Ok(wordcraft_doc::Part { kind: convert(source.kind)?, blocks: blocks_from_shared(source.blocks, graphics)?, body: convert(source.body)? })
|
||||
}
|
||||
|
||||
fn glossary_value_from_shared(source: craft_wordmodel::BuildingBlock) -> Result<wordcraft_doc::BuildingBlock, DocxError> {
|
||||
fn glossary_value_from_shared(
|
||||
source: craft_wordmodel::BuildingBlock,
|
||||
graphics: &mut GraphicCache,
|
||||
) -> Result<wordcraft_doc::BuildingBlock, DocxError> {
|
||||
Ok(wordcraft_doc::BuildingBlock {
|
||||
name: source.name,
|
||||
gallery: source.gallery,
|
||||
@@ -135,7 +156,7 @@ fn glossary_value_from_shared(source: craft_wordmodel::BuildingBlock) -> Result<
|
||||
style: source.style,
|
||||
types: source.types,
|
||||
behaviors: source.behaviors,
|
||||
blocks: blocks_from_shared(source.blocks)?,
|
||||
blocks: blocks_from_shared(source.blocks, graphics)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,7 +168,7 @@ fn missing_pictures_from_shared(source: craft_wordmodel::MissingPictures) -> Res
|
||||
target.part_names = source.part_names;
|
||||
target.media_parts = source.media_parts;
|
||||
for item in source.list {
|
||||
target.push(convert(item)?);
|
||||
target.list.push(convert(item)?);
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
@@ -156,13 +177,12 @@ fn missing_pictures_from_shared(source: craft_wordmodel::MissingPictures) -> Res
|
||||
pub fn from_shared_model(mut source: craft_wordmodel::Document) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
let mut fit_state = None;
|
||||
let fit_key = source.passthrough.keys().find(|k| k.eq_ignore_ascii_case(crate::fit::PART)).cloned();
|
||||
if let Some(key) = fit_key {
|
||||
if let Some(xml_bytes) = source.passthrough.get(&key) {
|
||||
if let Some(state) = crate::fit::read_from_bytes(xml_bytes) {
|
||||
fit_state = Some(state);
|
||||
source.passthrough.remove(&key);
|
||||
}
|
||||
}
|
||||
if let Some(key) = fit_key
|
||||
&& let Some(xml_bytes) = source.passthrough.get(&key)
|
||||
&& let Some(state) = crate::fit::read_from_bytes(xml_bytes)
|
||||
{
|
||||
fit_state = Some(state);
|
||||
source.passthrough.remove(&key);
|
||||
}
|
||||
|
||||
let craft_wordmodel::Document {
|
||||
@@ -186,10 +206,11 @@ pub fn from_shared_model(mut source: craft_wordmodel::Document) -> Result<wordcr
|
||||
..
|
||||
} = source;
|
||||
|
||||
let mut graphics = GraphicCache::new();
|
||||
let mut document = wordcraft_doc::Document::new();
|
||||
document.body = blocks_from_shared(body)?;
|
||||
document.body = blocks_from_shared(body, &mut graphics)?;
|
||||
document.last_section = convert(last_section)?;
|
||||
document.parts = parts.into_iter().map(|(id, part)| Ok((id, part_value_from_shared(part)?))).collect::<Result<_, DocxError>>()?;
|
||||
document.parts = parts.into_iter().map(|(id, part)| Ok((id, part_value_from_shared(part, &mut graphics)?))).collect::<Result<_, DocxError>>()?;
|
||||
document.styles = Arc::new(convert((*styles).clone())?);
|
||||
document.numbering = Arc::new(convert((*numbering).clone())?);
|
||||
document.comments = convert(comments)?;
|
||||
@@ -202,7 +223,7 @@ pub fn from_shared_model(mut source: craft_wordmodel::Document) -> Result<wordcr
|
||||
document.endnote_separators = convert(endnote_separators)?;
|
||||
document.media = media;
|
||||
document.passthrough = passthrough;
|
||||
document.glossary = glossary.into_iter().map(glossary_value_from_shared).collect::<Result<_, _>>()?;
|
||||
document.glossary = glossary.into_iter().map(|g| glossary_value_from_shared(g, &mut graphics)).collect::<Result<_, _>>()?;
|
||||
document.missing_pictures = missing_pictures_from_shared(missing_pictures)?;
|
||||
document.fit_state = fit_state;
|
||||
Ok(document)
|
||||
@@ -235,17 +256,24 @@ fn sanitize_para_props(mut props: wordcraft_doc::ParaProps) -> wordcraft_doc::Pa
|
||||
wordcraft_doc::props::LineSpacing::AtLeast(v) => *v = finite(*v),
|
||||
}
|
||||
}
|
||||
if let Some(v) = &mut props.indent_left { if !v.is_finite() { *v = 0.0; } }
|
||||
if let Some(v) = &mut props.indent_right { if !v.is_finite() { *v = 0.0; } }
|
||||
if let Some(v) = &mut props.indent_first { if !v.is_finite() { *v = 0.0; } }
|
||||
if let Some(v) = &mut props.space_before { if !v.is_finite() { *v = 0.0; } }
|
||||
if let Some(v) = &mut props.space_after { if !v.is_finite() { *v = 0.0; } }
|
||||
for field in [&mut props.indent_left, &mut props.indent_right, &mut props.indent_first, &mut props.space_before, &mut props.space_after] {
|
||||
if let Some(v) = field
|
||||
&& !v.is_finite()
|
||||
{
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
fn sanitize_char_props(mut props: wordcraft_doc::CharProps) -> wordcraft_doc::CharProps {
|
||||
if let Some(v) = &mut props.size { if !v.is_finite() { *v = 0.0; } }
|
||||
if let Some(v) = &mut props.spacing { if !v.is_finite() { *v = 0.0; } }
|
||||
for field in [&mut props.size, &mut props.spacing] {
|
||||
if let Some(v) = field
|
||||
&& !v.is_finite()
|
||||
{
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
@@ -265,31 +293,17 @@ fn paragraph_value_to_shared(source: &wordcraft_doc::para::Paragraph) -> Result<
|
||||
}
|
||||
|
||||
fn object_value_to_shared(source: &wordcraft_doc::para::InlineObject) -> Result<craft_wordmodel::para::InlineObject, DocxError> {
|
||||
use wordcraft_doc::para::InlineObject as Source;
|
||||
use craft_wordmodel::para::InlineObject as Target;
|
||||
use wordcraft_doc::para::InlineObject as Source;
|
||||
|
||||
match source {
|
||||
Source::Image {
|
||||
media,
|
||||
w,
|
||||
h,
|
||||
alt,
|
||||
float,
|
||||
crop,
|
||||
fx,
|
||||
ole,
|
||||
} => Ok(Target::Image {
|
||||
Source::Image { media, w, h, alt, float, crop, fx, ole } => Ok(Target::Image {
|
||||
media: media.clone(),
|
||||
w: finite(*w),
|
||||
h: finite(*h),
|
||||
alt: alt.clone(),
|
||||
float: convert_ref(float)?,
|
||||
crop: [
|
||||
finite(crop[0]),
|
||||
finite(crop[1]),
|
||||
finite(crop[2]),
|
||||
finite(crop[3]),
|
||||
],
|
||||
crop: [finite(crop[0]), finite(crop[1]), finite(crop[2]), finite(crop[3])],
|
||||
fx: convert_ref(fx)?,
|
||||
ole: ole.as_ref().map(|o| convert_ref(o.as_ref()).map(Arc::new)).transpose()?,
|
||||
}),
|
||||
@@ -336,11 +350,7 @@ fn cell_value_to_shared(source: &wordcraft_doc::table::Cell) -> Result<craft_wor
|
||||
}
|
||||
|
||||
fn part_value_to_shared(source: &wordcraft_doc::Part) -> Result<craft_wordmodel::Part, DocxError> {
|
||||
Ok(craft_wordmodel::Part {
|
||||
kind: convert_ref(&source.kind)?,
|
||||
blocks: blocks_to_shared(&source.blocks)?,
|
||||
body: convert_ref(&source.body)?,
|
||||
})
|
||||
Ok(craft_wordmodel::Part { kind: convert_ref(&source.kind)?, blocks: blocks_to_shared(&source.blocks)?, body: convert_ref(&source.body)? })
|
||||
}
|
||||
|
||||
fn glossary_value_to_shared(source: &wordcraft_doc::BuildingBlock) -> Result<craft_wordmodel::BuildingBlock, DocxError> {
|
||||
@@ -365,7 +375,7 @@ fn missing_pictures_to_shared(source: &wordcraft_doc::MissingPictures) -> Result
|
||||
target.part_names = source.part_names.clone();
|
||||
target.media_parts = source.media_parts.clone();
|
||||
for item in &source.list {
|
||||
target.push(convert_ref(item)?);
|
||||
target.list.push(convert_ref(item)?);
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Alternative format chunks (`w:altChunk`, ECMA-376 §17.17.2.1): content in another format that
|
||||
//! is brought in where the chunk stands. All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{Block, Document, InlineObject, StoryRef};
|
||||
use wordcraft_docx::{AltChunk, ReadOptions, read, read_with};
|
||||
|
||||
@@ -46,12 +44,11 @@ fn package_styled(body: &str, chunk: Option<(&str, &str, &[u8])>, styles: Option
|
||||
}
|
||||
types.push_str(r#"<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#);
|
||||
files.push(("[Content_Types].xml".into(), types.into_bytes()));
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in files {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&bytes).unwrap();
|
||||
zw.add(&name, &bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
const CHUNK: &str = r#"<w:altChunk r:id="rIdC"/>"#;
|
||||
@@ -144,12 +141,11 @@ fn package_with_stories(body: &str, stories: &[(&str, &str, String)], chunk: Opt
|
||||
if let Some(c) = chunk {
|
||||
files.push(("word/afchunk.docx".into(), c.to_vec()));
|
||||
}
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in files {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&bytes).unwrap();
|
||||
zw.add(&name, &bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A paragraph `text` that comment `id` (in the file) is anchored around.
|
||||
@@ -395,17 +391,16 @@ fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec<u8> {
|
||||
(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()));
|
||||
let mut zw = craft_zip::Writer::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.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A picture the chunk's own reader dropped is the file's too (#6005), named by the chunk part.
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
//! complex-script font, size, bold and italic, and the bidi language. Fixtures are hand-written
|
||||
//! from ECMA-376 Part 1 §17.3.1.6 (bidi), §17.3.2 (run properties).
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A package whose body is `body`.
|
||||
@@ -31,10 +28,9 @@ fn paras(d: &Document) -> Vec<&Paragraph> {
|
||||
|
||||
/// `word/document.xml` of a written package.
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Persian text with a ZWNJ (نیمفاصله), Persian digits, Latin and punctuation.
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//! direction changes and marks the right-to-left pieces `w:rtl`, as Word does with text typed
|
||||
//! in a right-to-left paragraph.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::CharProps;
|
||||
@@ -14,10 +13,9 @@ use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
|
||||
/// `word/document.xml` of a written package.
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The runs of the first paragraph of `xml`: (has `w:rtl`, text with `w:br` as `\n`).
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
|
||||
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
|
||||
const LINES: [(&str, BorderStyle); 9] = [
|
||||
@@ -23,19 +21,17 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn side(name: &str, val: &str) -> String {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Original partial-cell-margin fixture (ECMA-376 §17.4.69).
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let document = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:tbl><w:tblPr><w:tblCellMar><w:top w:w="55" w:type="dxa"/><w:left w:w="45" w:type="dxa"/><w:bottom w:w="55" w:type="dxa"/><w:right w:w="55" w:type="dxa"/></w:tblCellMar></w:tblPr><w:tblGrid><w:gridCol w:w="4320"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcMar><w:left w:w="0" w:type="dxa"/></w:tcMar></w:tcPr><w:p><w:r><w:t>Point</w:t></w:r></w:p></w:tc></w:tr></w:tbl></w:body></w:document>"#;
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
zip.start_file("word/document.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(document.as_bytes()).unwrap();
|
||||
zip.finish().unwrap().into_inner()
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
zip.add("word/document.xml", document.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -19,9 +17,8 @@ fn partial_margin_stays_partial() {
|
||||
let reopened = wordcraft_docx::read(&bytes).unwrap();
|
||||
let cell = &reopened.body[0].as_table().unwrap().rows[0].cells[0];
|
||||
assert_eq!(cell.props.resolved_margins([6.0, 7.0, 8.0, 9.0]), [6.0, 0.0, 8.0, 9.0]);
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let margins = xml.split("<w:tcMar>").nth(1).unwrap().split("</w:tcMar>").next().unwrap();
|
||||
assert!(margins.contains("<w:left w:w=\"0\""));
|
||||
assert!(!margins.contains("<w:top"));
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
//! `w:ind` `w:firstLineChars`, `w:hangingChars`, `w:leftChars` and `w:rightChars`, in hundredths
|
||||
//! of a character. Fixtures are hand-written.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A package whose body is `body`.
|
||||
@@ -44,9 +41,9 @@ fn character_indents_read_and_round_trip() {
|
||||
// Hostile values are clamped or dropped.
|
||||
assert_eq!((p[3].indent_first_chars, p[3].indent_left_chars), (Some(1584.0), None));
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let _z_bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
for attr in [r#"w:firstLineChars="200""#, r#"w:leftChars="150""#, r#"w:rightChars="50""#, r#"w:hangingChars="100""#, r#"w:firstLineChars="0""#] {
|
||||
assert!(xml.contains(attr), "{attr} missing: {xml}");
|
||||
}
|
||||
@@ -71,9 +68,9 @@ fn automatic_spacing_reads_and_round_trips() {
|
||||
assert_eq!((rp.space_before, rp.space_after, rp.before_auto), (14.0, 14.0, true), "automatic spacing is 14 pt");
|
||||
assert_eq!(d.styles.resolve_para(&p[2]).space_after, 5.0);
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let _z_bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
for attr in [r#"w:beforeAutospacing="1""#, r#"w:afterAutospacing="1""#, r#"w:beforeAutospacing="0""#] {
|
||||
assert!(xml.contains(attr), "{attr} missing");
|
||||
}
|
||||
@@ -90,9 +87,8 @@ fn adjust_right_indent_is_preserved() {
|
||||
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&out, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
|
||||
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
|
||||
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! multi-paragraph results, and custom document properties round-trip. All markup here is
|
||||
//! synthetic, written from ECMA-376.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::{CharProps, Document, InlineObject, Path, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
@@ -16,12 +14,11 @@ const CITE_B: &str = r#""citationItems":[{"id":7,"uris":["http://zotero.org/user
|
||||
const BIBL: &str = r#" ADDIN ZOTERO_BIBL {"uncited":[],"omitted":[],"custom":[]} CSL_BIBLIOGRAPHY "#;
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn fld(kind: &str) -> String {
|
||||
@@ -129,10 +126,9 @@ fn count(xml: &str, kind: &str) -> usize {
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -174,9 +170,8 @@ fn custom_property_bookmark_links_survive_saves_and_value_edits() {
|
||||
let before = d.custom_props.clone();
|
||||
for _ in 0..2 {
|
||||
let out = write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("docProps/custom.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&out, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("docProps/custom.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"linkTarget="OriginalBookmark""#), "{xml}");
|
||||
assert!(xml.contains(r#"linkTarget="RawBookmark""#), "{xml}");
|
||||
assert_eq!(xml.matches("linkTarget=").count(), 2, "unlinked properties stay unlinked");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Independent complex-script formatting through style inheritance and DOCX round trips.
|
||||
//! Synthetic packages, written here; no external documents or installed fonts are needed.
|
||||
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, Run};
|
||||
|
||||
@@ -13,17 +12,16 @@ fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Ve
|
||||
r#"<w:document {ns}><w:body><w:p><w:pPr>{paragraph_style}<w:bidi/></w:pPr><w:r><w:rPr>{run}</w:rPr><w:t>שָׁלוֹם ABC 123</w:t></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!("<w:styles {ns}><w:docDefaults><w:rPrDefault><w:rPr>{defaults}</w:rPr></w:rPrDefault></w:docDefaults>{styles}</w:styles>");
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [
|
||||
("_rels/.rels", root),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn paragraph(d: &Document) -> &Paragraph {
|
||||
@@ -180,9 +178,14 @@ fn split_runs_add_no_phantom_tracked_formatting() {
|
||||
let run = r#"<w:sz w:val="24"/><w:rPrChange w:id="7" w:author="Synthetic Author" w:date="2026-10-11T00:00:00Z"><w:rPr><w:sz w:val="20"/></w:rPr></w:rPrChange>"#;
|
||||
let d = wordcraft_docx::read(&package(run, "", "", "")).unwrap();
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("word/document.xml").unwrap(), &mut xml)
|
||||
.unwrap();
|
||||
let xml = String::from_utf8(
|
||||
craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 })
|
||||
.unwrap()
|
||||
.read("word/document.xml", 16 << 20)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let runs: Vec<&str> = xml.split("<w:r>").skip(1).map(|r| r.split("</w:r>").next().unwrap()).collect();
|
||||
assert!(runs.len() >= 2, "the mixed run is split: {xml}");
|
||||
assert!(runs.iter().any(|r| r.contains("<w:rtl/>")), "{xml}");
|
||||
|
||||
@@ -136,18 +136,14 @@ fn the_controls_are_written_where_they_were() {
|
||||
|
||||
/// The names of the parts of a package, in the order they are in it.
|
||||
fn part_names(bytes: &[u8]) -> Vec<String> {
|
||||
let z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
z.file_names().map(str::to_string).collect()
|
||||
let z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).expect("zip");
|
||||
z.names().map(|n| n.into_owned()).collect()
|
||||
}
|
||||
|
||||
/// The body of the document part of a package.
|
||||
fn document_body(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
{
|
||||
use std::io::Read;
|
||||
z.by_name("word/document.xml").expect("the document part").read_to_string(&mut xml).expect("read");
|
||||
}
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).expect("zip");
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).expect("read").expect("the document part")).expect("utf8");
|
||||
let rest = xml.split_once("<w:body>").expect("the body").1;
|
||||
let body = rest.split_once("</w:body>").expect("the end of the body").0;
|
||||
format!("<w:body>{body}</w:body>")
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! All markup here is synthetic, written from ECMA-376 §17.5.2 and the [MS-DOCX] `w14`/`w15`
|
||||
//! extensions.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::control::{ControlKind, ControlLock, ListItem};
|
||||
use wordcraft_doc::{Block, ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
@@ -13,12 +11,11 @@ const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.o
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
@@ -90,10 +87,9 @@ fn form() -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn controls(d: &Document) -> Vec<ContentControl> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Original synthetic custom XML stores, written from the public OOXML package contract.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use wordcraft_doc::{Path, StoryRef};
|
||||
|
||||
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
@@ -36,22 +35,19 @@ fn fixture() -> BTreeMap<String, Vec<u8>> {
|
||||
}
|
||||
|
||||
fn zip(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, bytes) in parts {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
z.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut parts = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut bytes = Vec::new();
|
||||
f.read_to_end(&mut bytes).unwrap();
|
||||
for name in names {
|
||||
let bytes = z.read(&name, 16 << 20).unwrap().unwrap();
|
||||
assert!(parts.insert(name, bytes).is_none(), "duplicate ZIP entry");
|
||||
}
|
||||
parts
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! The document default font size (`w:docDefaults/w:rPrDefault/w:rPr/w:sz`) across a save and
|
||||
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
|
||||
//! the reader reads an omitted size as 10 pt.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
|
||||
@@ -9,10 +8,9 @@ fn resolved(d: &Document) -> f32 {
|
||||
d.styles.resolve_char(None, &CharProps::default()).size
|
||||
}
|
||||
fn styles_xml(bytes: &[u8]) -> String {
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
archive.by_name("word/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
let mut archive = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(archive.read("word/styles.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
fn defaults(xml: &str) -> &str {
|
||||
xml.split("<w:docDefaults>").nth(1).unwrap().split("</w:docDefaults>").next().unwrap()
|
||||
@@ -47,12 +45,11 @@ fn a_file_that_omits_the_default_size_is_still_read_as_ten_points() {
|
||||
let doc = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>x</w:t></w:r></w:p></w:body></w:document>"#;
|
||||
let styles = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr/></w:rPrDefault></w:docDefaults></w:styles>"#;
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, body) in [("_rels/.rels", root), ("word/document.xml", doc), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles)] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
zw.add(name, body.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let d = wordcraft_docx::read(&zw.finish().unwrap().into_inner()).unwrap();
|
||||
let d = wordcraft_docx::read(&zw.finish().unwrap()).unwrap();
|
||||
assert_eq!(d.styles.default_chr.size, Some(10.0));
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Original nested-picture controls. No native output or external assets are embedded.
|
||||
use std::{io::Write, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::{Document, InlineObject, Paragraph, PartKind, Pos, StoryRef, TextBody, para_block};
|
||||
@@ -71,7 +71,7 @@ fn check_box(doc: &Document) {
|
||||
assert_eq!(doc.media[media].as_slice(), BLUE_PNG);
|
||||
}
|
||||
fn package(xml: &str) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let docrels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdIsolationRect" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/isolation-blue.png"/></Relationships>"#;
|
||||
let types = br#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
@@ -82,10 +82,9 @@ fn package(xml: &str) -> Vec<u8> {
|
||||
("word/_rels/document.xml.rels", docrels.as_slice()),
|
||||
("word/media/isolation-blue.png", BLUE_PNG),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
zip.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn model_nested_picture_roundtrip() {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! East Asian language attribute of a run (`w:lang/@w:eastAsia`, ISO/IEC 29500-1 §17.3.2.20).
|
||||
//! Verifies that saving DOCX documents preserves the East Asian language of `w:lang`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
@@ -17,19 +15,17 @@ fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{s}</w:styles>"#),
|
||||
));
|
||||
}
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, xml) in &parts {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
zw.add(name, xml.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read(name, 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn run(text: &str, rpr: &str) -> String {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! two lines in one and horizontal in vertical are read and saved again. Fixtures are
|
||||
//! hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::{CombineBrackets, EastAsianLayout};
|
||||
|
||||
fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
@@ -20,19 +18,17 @@ fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{s}</w:styles>"#),
|
||||
));
|
||||
}
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, xml) in &parts {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
zw.add(name, xml.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read(name, 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn run(text: &str, rpr: &str) -> String {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Emphasis marks (`w:em`, ISO/IEC 29500-1 §17.3.2.12: Font › Emphasis mark) are read and
|
||||
//! saved again, in their place in `w:rPr`. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::EmphasisMark;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
@@ -10,12 +8,11 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, xml) in [("_rels/.rels", root.to_string()), ("word/document.xml", doc)] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
zw.add(name, xml.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn marks(d: &wordcraft_doc::Document) -> Vec<Option<EmphasisMark>> {
|
||||
@@ -39,9 +36,8 @@ fn emphasis_marks_are_read_and_saved() {
|
||||
let want = [EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot, EmphasisMark::None].map(Some);
|
||||
assert_eq!(marks(&d), want);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&saved, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
// In its place: after w:cs, before w:lang.
|
||||
assert!(xml.contains(r#"<w:cs/><w:em w:val="underDot"/><w:lang"#), "{xml}");
|
||||
assert_eq!(marks(&wordcraft_docx::read(&saved).unwrap()), want);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
@@ -13,12 +12,11 @@ const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingm
|
||||
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
pub fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn rels(list: &[(&str, &str, &str)]) -> String {
|
||||
@@ -1483,10 +1481,9 @@ fn word_watermark_survives_save() {
|
||||
};
|
||||
assert_eq!(headers(&d2), headers(&d));
|
||||
assert_eq!(headers(&d2)[0], vec!["Header label".to_string(), "Second line".to_string()]);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
for name in ["word/header1.xml", "word/header2.xml"] {
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
|
||||
let xml = String::from_utf8(z.read(name, 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(xml.matches("<v:shape ").count(), 1, "{name}");
|
||||
}
|
||||
|
||||
@@ -1662,14 +1659,12 @@ fn chart_docx() -> Vec<u8> {
|
||||
|
||||
/// The entries of a zip, by name.
|
||||
fn unzip(bytes: &[u8]) -> std::collections::BTreeMap<String, Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).unwrap();
|
||||
out.insert(f.name().to_string(), b);
|
||||
for name in names {
|
||||
let b = z.read(&name, 16 << 20).unwrap().unwrap();
|
||||
out.insert(name, b);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -2174,9 +2169,8 @@ fn formatting_revisions_round_trip() {
|
||||
|
||||
// Written back: each change is the last child of its parent, and reads back the same.
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
for (tag, parent) in [
|
||||
("w:rPrChange", "</w:rPr></w:rPrChange></w:rPr>"),
|
||||
("w:pPrChange", "</w:pPr></w:pPrChange></w:pPr>"),
|
||||
@@ -2432,10 +2426,9 @@ fn note_separators_and_continuation_notice_round_trip() {
|
||||
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let part = |name: &str| {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
let mut z = craft_zip::Archive::open(&out, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read(name, 16 << 20).unwrap().unwrap()).unwrap()
|
||||
};
|
||||
let notes = part("word/footnotes.xml");
|
||||
for (kind, id) in [("separator", "-1"), ("continuationSeparator", "0"), ("continuationNotice", "1")] {
|
||||
@@ -2558,13 +2551,12 @@ fn includepicture_keeps_its_embedded_picture() {
|
||||
let bytes = docx(&format!("<w:p>{field}</w:p><w:sectPr/>"), &[("rIdPic", "image", "media/picture.png")], &[]);
|
||||
let bytes = {
|
||||
// `docx` takes text parts; add the picture bytes as a binary part.
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut v = Vec::new();
|
||||
std::io::Read::read_to_end(&mut f, &mut v).unwrap();
|
||||
entries.push((f.name().to_string(), v));
|
||||
for name in names {
|
||||
let v = z.read(&name, 16 << 20).unwrap().unwrap();
|
||||
entries.push((name, v));
|
||||
}
|
||||
entries.push(("word/media/picture.png".into(), png.to_vec()));
|
||||
zip(&entries.iter().map(|(n, v)| (n.as_str(), v.as_slice())).collect::<Vec<_>>())
|
||||
@@ -2678,9 +2670,8 @@ fn rounded_rectangle_corners_are_read_and_saved() {
|
||||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||||
assert_eq!(corners(&d), expected);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut xml = String::new();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val 3565"/></a:avLst></a:prstGeom>"#), "{xml}");
|
||||
assert_eq!(corners(&wordcraft_docx::read(&bytes).unwrap()), expected);
|
||||
}
|
||||
@@ -2745,9 +2736,8 @@ fn arrowheads_keep_their_kind_and_size() {
|
||||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||||
assert_eq!(ends(&d), expected);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut xml = String::new();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<a:headEnd type="stealth" w="sm" len="lg"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:tailEnd type="diamond" w="lg" len="sm"/>"#), "{xml}");
|
||||
assert_eq!(ends(&wordcraft_docx::read(&bytes).unwrap()), expected);
|
||||
@@ -2870,9 +2860,8 @@ fn vml_preset_shape_survives_saving() {
|
||||
r##"<w:p><w:r><w:pict>{VML_RIGHT_ARROW_TYPE}<v:shape type="#_x0000_t13" style="width:120pt;height:40pt" fillcolor="#ffa500"/></w:pict></w:r></w:p>"##
|
||||
);
|
||||
let saved = wordcraft_docx::write(&read_body(&body)).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&saved, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"prst="rightArrow""#), "{xml}");
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert!(
|
||||
@@ -2898,9 +2887,8 @@ fn vml_preset_shape_survives_saving() {
|
||||
fn vml_nearest_preset_shape_survives_saving() {
|
||||
let body = r##"<w:p><w:r><w:pict><v:shape type="#_x0000_t66" style="width:60pt;height:20pt" fillcolor="#ffa500"/></w:pict></w:r></w:p>"##;
|
||||
let saved = wordcraft_docx::write(&read_body(body)).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(saved.as_slice())).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&saved, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"prst="leftArrow""#), "{xml}");
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert!(
|
||||
@@ -3047,9 +3035,8 @@ fn drawingml_shapes_survive_saving() {
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(shapes(&again), read, "every shape survives saving");
|
||||
assert_eq!(part_text_box(&again), "Floating text box");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes[..], craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(xml.matches("<wps:wsp>").count(), 7);
|
||||
for p in presets.iter().chain(&["straightConnector1"]) {
|
||||
assert!(xml.contains(&format!(r#"<a:prstGeom prst="{p}">"#)), "{p} is written");
|
||||
@@ -4044,20 +4031,19 @@ fn hyperlink_with_empty_target_is_no_link() {
|
||||
let bytes = docx(body, &[("rId2", "hyperlink", "https://x"), ("rId3", "hyperlink", "mailto:a@example.com")], &[]);
|
||||
// Blank the first target and mark the mailto one external (the helper only does that for http).
|
||||
let bytes = {
|
||||
let mut zr = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut zr = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = zr.names().map(|n| n.into_owned()).collect();
|
||||
let mut entries = Vec::new();
|
||||
for i in 0..zr.len() {
|
||||
let mut f = zr.by_index(i).unwrap();
|
||||
let mut buf = Vec::new();
|
||||
std::io::Read::read_to_end(&mut f, &mut buf).unwrap();
|
||||
if f.name() == "word/_rels/document.xml.rels" {
|
||||
for name in names {
|
||||
let mut buf = zr.read(&name, 16 << 20).unwrap().unwrap();
|
||||
if name == "word/_rels/document.xml.rels" {
|
||||
buf = String::from_utf8(buf)
|
||||
.unwrap()
|
||||
.replace("Target=\"https://x\"", "Target=\"\"")
|
||||
.replace("a@example.com\"", "a@example.com\" TargetMode=\"External\"")
|
||||
.into_bytes();
|
||||
}
|
||||
entries.push((f.name().to_string(), buf));
|
||||
entries.push((name, buf));
|
||||
}
|
||||
let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
|
||||
zip(&refs)
|
||||
@@ -4575,7 +4561,7 @@ fn shape_gradients_and_transparency_are_read_and_survive_saving() {
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::Xy,
|
||||
}))),
|
||||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(0x20, 0x40, 0x60), 0.0)], lin(90.0, false), true)),
|
||||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(44, 92, 140), 0.0)], lin(90.0, false), true)),
|
||||
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, black, 0.0)], centre, true)),
|
||||
None,
|
||||
];
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! edited. Hand-written fixtures (from the spec), no files produced by Word.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block};
|
||||
@@ -15,22 +14,20 @@ const GLOSSARY_REL: &str = "http://schemas.openxmlformats.org/officeDocument/200
|
||||
const GLOSSARY_CT: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml";
|
||||
|
||||
fn zip(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).unwrap();
|
||||
assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name());
|
||||
for name in names {
|
||||
let b = z.read(&name, 16 << 20).unwrap().unwrap();
|
||||
assert!(out.insert(name.clone(), b).is_none(), "duplicate part {name}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows,
|
||||
//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever
|
||||
//! their style ids) and body text for a custom style.
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::Paragraph;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
@@ -12,14 +11,13 @@ fn package(styles: &str, body: &str) -> Vec<u8> {
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#);
|
||||
let styles = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}">{styles}</w:styles>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, body) in
|
||||
[("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())]
|
||||
{
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
zw.add(name, body.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn para(style: &str, text: &str) -> String {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! (`w:gridBefore`/`w:wBefore`, `w:gridAfter`/`w:wAfter`) through a round trip (#1174). All
|
||||
//! markup here is synthetic, written from ECMA-376 §17.16.17 and §17.4.14-17.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::Block;
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
@@ -12,19 +10,17 @@ const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.o
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn resaved(body: &str) -> String {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! OpenType ligatures (`w14:ligatures`, [MS-DOCX]) in document defaults, styles and runs.
|
||||
//! Synthetic packages, written here; no external documents are needed.
|
||||
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::props::Ligatures;
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
|
||||
@@ -15,17 +14,16 @@ fn package(defaults: &str, style: &str, run: &str) -> Vec<u8> {
|
||||
let styles = format!(
|
||||
r#"<w:styles {ns}><w:docDefaults><w:rPrDefault><w:rPr>{defaults}</w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="character" w:styleId="Fancy"><w:name w:val="Fancy"/><w:rPr>{style}</w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [
|
||||
("_rels/.rels", root),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn paragraph(d: &Document) -> &Paragraph {
|
||||
@@ -68,10 +66,9 @@ fn unknown_ligature_values_are_ignored() {
|
||||
fn no_ligatures_element_without_a_value() {
|
||||
let d = wordcraft_docx::read(&package("", "", "")).unwrap();
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&out, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
for name in ["word/document.xml", "word/styles.xml"] {
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
|
||||
let xml = String::from_utf8(z.read(name, 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(!xml.contains("ligatures"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingm
|
||||
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn doc_zip(document_xml: &[u8]) -> Vec<u8> {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
//! 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
|
||||
wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn blip(attrs: &str) -> String {
|
||||
@@ -32,10 +31,9 @@ fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&
|
||||
);
|
||||
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 zip = craft_zip::Writer::new();
|
||||
let mut put = |name: &str, bytes: &[u8]| {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
zip.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
};
|
||||
put("[Content_Types].xml", types.as_bytes());
|
||||
put("_rels/.rels", rels.as_bytes());
|
||||
@@ -51,7 +49,7 @@ fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&
|
||||
for (n, b) in extra {
|
||||
put(n, b);
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
fn image_rel(id: &str, target: &str, external: bool) -> String {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
|
||||
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
@@ -13,19 +11,17 @@ const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.o
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! and extended properties. Hand-written fixtures (from the spec), no files produced by Word.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
@@ -12,22 +11,20 @@ const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relati
|
||||
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn zip(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).unwrap();
|
||||
assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name());
|
||||
for name in names {
|
||||
let b = z.read(&name, 16 << 20).unwrap().unwrap();
|
||||
assert!(out.insert(name.clone(), b).is_none(), "duplicate part {name}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
|
||||
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
|
||||
//! synthetic packages; the 2×2 picture is generated in code.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// An inline picture's run; `link` goes into its `wp:docPr`.
|
||||
@@ -28,7 +27,7 @@ fn package(body: &str) -> Vec<u8> {
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let png = png();
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, bytes) in [
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
@@ -36,10 +35,9 @@ fn package(body: &str) -> Vec<u8> {
|
||||
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
|
||||
("word/media/square.png", png.as_slice()),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
zip.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
/// The link on each picture of the first paragraph, in order.
|
||||
@@ -53,10 +51,9 @@ fn picture_links(doc: &Document) -> Vec<Option<String>> {
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read(name, 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -109,19 +106,16 @@ fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
|
||||
// The drawing's link alone (as Word writes it) reads back the same.
|
||||
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
|
||||
assert_ne!(without_run_link, xml);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = craft_zip::Writer::new();
|
||||
for name in names {
|
||||
let mut data = z.read(&name, 16 << 20).unwrap().unwrap();
|
||||
if name == "word/document.xml" {
|
||||
data = without_run_link.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
out.add(&name, &data, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let edited = out.finish().unwrap().into_inner();
|
||||
let edited = out.finish().unwrap();
|
||||
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Positional tabs (`w:ptab`, ECMA-376 Part 1 §17.3.3.23). Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::InlineObject;
|
||||
use wordcraft_doc::para::{PTabAlign, PTabBase};
|
||||
use wordcraft_doc::props::TabLeader;
|
||||
@@ -11,19 +9,17 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn ptab(relative_to: &str, alignment: &str, leader: &str) -> String {
|
||||
|
||||
@@ -2,28 +2,27 @@
|
||||
//! (ECMA-376 Part 1 §17.15.1.69–70) are kept on save and honoured by the writer. Synthetic
|
||||
//! fixtures written for these tests from the spec; the names are made up.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
||||
|
||||
fn zip(entries: &[(&str, String)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, text) in entries {
|
||||
z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(text.as_bytes()).unwrap();
|
||||
z.add(name, text.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, String> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut parts = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut s = String::new();
|
||||
if f.read_to_string(&mut s).is_ok() {
|
||||
parts.insert(f.name().to_string(), s);
|
||||
for name in names {
|
||||
if let Ok(Some(bytes)) = z.read(&name, 16 << 20)
|
||||
&& let Ok(s) = String::from_utf8(bytes)
|
||||
{
|
||||
parts.insert(name, s);
|
||||
}
|
||||
}
|
||||
parts
|
||||
|
||||
@@ -63,7 +63,11 @@ fn para_runs(runs: &[(&str, CharProps)]) -> Paragraph {
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
std::io::Write::write_all(
|
||||
&mut out,
|
||||
&wordcraft_geom::picture::encode_format(&image::DynamicImage::from(img.clone()), image::ImageFormat::Png).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
@@ -324,9 +328,8 @@ fn tracked_style_definition_change_round_trips() {
|
||||
});
|
||||
d.body = vec![para_block(Paragraph::with_text("x", CharProps::default()))];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/styles.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/styles.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains("<w:pPrChange") && xml.contains("<w:rPrChange"), "{xml}");
|
||||
assert!(!xml.contains("stylePrChange"), "CT_Style has no such element: {xml}");
|
||||
drop(zip);
|
||||
@@ -748,9 +751,8 @@ fn note_options_round_trip_per_section() {
|
||||
assert!(sections[1].1.footnote_pr.is_empty() && sections[1].1.endnote_pr.is_empty());
|
||||
assert_eq!(r.note_options(sections[0].1, false).num_start, 7);
|
||||
assert_eq!(r.note_options(sections[1].1, false).num_start, 4);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let at = |s: &str| xml.find(s).unwrap_or_else(|| panic!("{s} missing"));
|
||||
assert!(
|
||||
at("<w:footnotePr><w:numFmt w:val=\"lowerRoman\"/><w:numStart w:val=\"7\"/><w:numRestart w:val=\"eachSect\"/></w:footnotePr>")
|
||||
@@ -773,9 +775,8 @@ fn note_never_references_itself() {
|
||||
d.body = vec![para_block(p)];
|
||||
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/footnotes.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/footnotes.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(!xml.contains("<w:footnoteReference"), "a footnote cites itself: {xml}");
|
||||
assert_eq!(xml.matches("<w:footnoteRef/>").count(), 1, "{xml}");
|
||||
|
||||
@@ -840,13 +841,12 @@ fn word_note_mark_round_trips_once() {
|
||||
let mut doc = d;
|
||||
for pass in 0..2 {
|
||||
let bytes = wordcraft_docx::write(&doc).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
for (file, mark, reference, style) in [
|
||||
("word/footnotes.xml", "<w:footnoteRef/>", "<w:footnoteReference", "FootnoteReference"),
|
||||
("word/endnotes.xml", "<w:endnoteRef/>", "<w:endnoteReference", "EndnoteReference"),
|
||||
] {
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name(file).unwrap(), &mut xml).unwrap();
|
||||
let xml = String::from_utf8(zip.read(file, 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(xml.matches(mark).count(), 1, "pass {pass}: {xml}");
|
||||
assert!(!xml.contains(reference), "pass {pass}: a note cites itself: {xml}");
|
||||
assert!(xml.contains(&format!(r#"<w:rStyle w:val="{style}"/></w:rPr>{mark}"#)), "pass {pass}: {xml}");
|
||||
@@ -894,9 +894,8 @@ fn toc_field_wraps_its_entries() {
|
||||
let d = doc_with(blocks);
|
||||
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let sdt = xml.find("<w:sdt>").expect("TOC content control");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "{xml}");
|
||||
let (begin, fend) = (xml.find(r#"fldCharType="begin""#).unwrap(), xml.find(r#"fldCharType="end""#).unwrap());
|
||||
@@ -933,9 +932,8 @@ fn cell_text_direction_round_trips() {
|
||||
let mut d = Document::new();
|
||||
d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:textDirection w:val="btLr"/>"#), "{xml}");
|
||||
assert_eq!(xml.matches("w:textDirection").count(), 2, "horizontal cells write nothing: {xml}");
|
||||
@@ -988,9 +986,8 @@ fn toc_field_skips_nested_stories() {
|
||||
d.body = vec![para_block(head), para_block(last), para_block(Paragraph::with_text("Body", CharProps::default()))];
|
||||
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let boxes: Vec<(usize, usize)> = xml.match_indices("<w:txbxContent>").map(|(a, _)| (a, a + xml[a..].find("</w:txbxContent>").unwrap())).collect();
|
||||
assert_eq!(boxes.len(), 2, "{xml}");
|
||||
let in_box = |i: usize| boxes.iter().any(|(a, b)| (*a..*b).contains(&i));
|
||||
@@ -1054,9 +1051,8 @@ fn tracked_equations_round_trip() {
|
||||
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
|
||||
d.body = vec![para_block(p.clone())];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
|
||||
assert!(
|
||||
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
|
||||
@@ -1119,9 +1115,8 @@ fn tracked_paragraph_marks_round_trip() {
|
||||
let last = Paragraph::with_text("plain", CharProps::default());
|
||||
d.body = vec![para_block(split), para_block(joined), para_block(last)];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<w:rPr><w:ins w:id="#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:rPr><w:del w:id="#), "{xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
@@ -1165,10 +1160,9 @@ fn fields_and_special_chars_round_trip() {
|
||||
|
||||
/// The `document.xml` of a written package.
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
let mut zip = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// #877: a field begin marker's `w:dirty` (the cached result is stale; Word refreshes it on
|
||||
@@ -1446,9 +1440,8 @@ fn drawing_canvas_round_trips() {
|
||||
p.insert_object(end, canvas, &CharProps::default()).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains("wordprocessingCanvas") && xml.contains("<wpc:wpc>") && xml.contains("<wpc:bg>") && xml.contains("<wpc:whole>"), "{xml}");
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
let got = paras(&r);
|
||||
@@ -1494,9 +1487,8 @@ fn rotated_drawing_canvas_round_trips() {
|
||||
let end = p.len();
|
||||
p.insert_object(end, canvas(square), &CharProps::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(
|
||||
xml.contains(r#"<wpc:extLst><a:ext uri="urn:wordcraft:canvas-xfrm"><a:xfrm rot="1800000" flipH="1"/></a:ext></wpc:extLst></wpc:wpc>"#),
|
||||
"{xml}"
|
||||
@@ -1524,9 +1516,8 @@ fn table_contextual_spacing_compatibility_flag_round_trips() {
|
||||
assert_eq!(r.settings.compat_mode, mode);
|
||||
if allow {
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/settings.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
// CT_Compat requires the boolean option before compatSetting.
|
||||
assert!(xml.find("<w:allowSpaceOfSameStyleInTable").unwrap() < xml.find("<w:compatSetting").unwrap());
|
||||
}
|
||||
@@ -1557,15 +1548,13 @@ fn attached_template_and_link_styles_round_trip() {
|
||||
d.settings.mirror_margins = true;
|
||||
d.settings.track_changes = true;
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/settings.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let at = |tag: &str| xml.find(tag).unwrap_or_else(|| panic!("{tag} missing: {xml}"));
|
||||
assert!(at("<w:mirrorMargins") < at("<w:attachedTemplate"));
|
||||
assert!(at("<w:attachedTemplate") < at("<w:linkStyles"));
|
||||
assert!(at("<w:linkStyles") < at("<w:trackRevisions"));
|
||||
let mut rels = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/_rels/settings.xml.rels").unwrap(), &mut rels).unwrap();
|
||||
let rels = String::from_utf8(zip.read("word/_rels/settings.xml.rels", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(rels.contains("relationships/attachedTemplate") && rels.contains("TargetMode=\"External\""), "{rels}");
|
||||
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
@@ -1583,10 +1572,9 @@ fn attached_template_and_link_styles_round_trip() {
|
||||
#[test]
|
||||
fn zoom_round_trips() {
|
||||
let settings = |bytes: &[u8]| {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
let mut zip = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(zip.read("word/settings.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
};
|
||||
let plain = wordcraft_docx::write(&Document::new()).unwrap();
|
||||
let xml = settings(&plain);
|
||||
@@ -1649,10 +1637,9 @@ fn settings_core_theme_round_trip() {
|
||||
}
|
||||
|
||||
fn part_xml(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read(name, 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Header parts in the document, by id.
|
||||
@@ -2047,9 +2034,8 @@ fn char_border_written_between_u_and_shd() {
|
||||
let c =
|
||||
CharProps { border: Some(Border::single(0.5)), underline: Some(Underline::Single), shading: Some(Rgb(0xFF, 0xFF, 0)), ..Default::default() };
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![para_runs(&[("x", c)])])).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let bdr = r#"<w:bdr w:val="single" w:sz="4" w:space="0" w:color="auto"/>"#;
|
||||
assert_eq!(xml.matches(bdr).count(), 1, "{xml}");
|
||||
let at = xml.find(bdr).unwrap();
|
||||
@@ -2146,9 +2132,8 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() {
|
||||
}
|
||||
let d = doc_with(vec![p]);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(xml.matches("<a:custGeom>").count(), 3, "{xml}");
|
||||
assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}");
|
||||
@@ -2188,9 +2173,8 @@ fn shape_effects_round_trip() {
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(1, shape, &CharProps::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(
|
||||
xml.contains(
|
||||
r#"<a:outerShdw blurRad="50800" dist="38100" dir="8100000" algn="tr" rotWithShape="0"><a:srgbClr val="203040"><a:alpha val="40000"/>"#
|
||||
@@ -2281,9 +2265,8 @@ fn rotation_and_flips_round_trip() {
|
||||
}
|
||||
d.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<a:xfrm rot="1800000">"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:xfrm rot="5400000" flipH="1">"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:xfrm rot="18930000" flipV="1">"#), "{xml}");
|
||||
@@ -2334,9 +2317,8 @@ fn rotated_group_and_shadow_rotation_round_trip() {
|
||||
let mut p = Paragraph::with_text("g", CharProps::default());
|
||||
p.insert_object(1, group, &CharProps::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<wpg:grpSpPr><a:xfrm rot="8100000" flipH="1">"#), "{xml}");
|
||||
assert!(xml.contains(r#"rotWithShape="1""#) && xml.contains(r#"rotWithShape="0""#), "{xml}");
|
||||
let r = wordcraft_docx::read(&bytes).expect("read");
|
||||
@@ -2440,9 +2422,8 @@ fn a_corporate_author_round_trips() {
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let mut item_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes[..], craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let item_xml = String::from_utf8(zip.read("customXml/item1.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(item_xml.contains("<b:Author><b:Author><b:Corporate>World Health Organization</b:Corporate></b:Author><b:Editor>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Corporate>Harbor Guild, Inc.</b:Corporate>"), "{item_xml}");
|
||||
assert!(!item_xml.contains("<b:Last>Rivera</b:Last>"), "the corporate author takes the person authors' place: {item_xml}");
|
||||
@@ -2452,23 +2433,20 @@ fn a_corporate_author_round_trips() {
|
||||
}
|
||||
|
||||
fn assert_bibliography_parts(bytes: &[u8]) {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
assert!(zip.by_name("customXml/item1.xml").is_ok());
|
||||
assert!(zip.by_name("customXml/itemProps1.xml").is_ok());
|
||||
assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok());
|
||||
let mut zip = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
assert!(zip.contains("customXml/item1.xml"));
|
||||
assert!(zip.contains("customXml/itemProps1.xml"));
|
||||
assert!(zip.contains("customXml/_rels/item1.xml.rels"));
|
||||
|
||||
let mut rels_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("_rels/.rels").unwrap(), &mut rels_xml).unwrap();
|
||||
let rels_xml = String::from_utf8(zip.read("_rels/.rels", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(rels_xml.contains("relationships/customXml"), "{rels_xml}");
|
||||
assert!(rels_xml.contains("Target=\"customXml/item1.xml\""), "{rels_xml}");
|
||||
|
||||
let mut ct_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut ct_xml).unwrap();
|
||||
let ct_xml = String::from_utf8(zip.read("[Content_Types].xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(ct_xml.contains("PartName=\"/customXml/item1.xml\""), "{ct_xml}");
|
||||
assert!(ct_xml.contains("PartName=\"/customXml/itemProps1.xml\""), "{ct_xml}");
|
||||
|
||||
let mut item_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
|
||||
let item_xml = String::from_utf8(zip.read("customXml/item1.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_bibliography_types(&item_xml);
|
||||
assert_bibliography_people(&item_xml);
|
||||
assert_bibliography_details(&item_xml);
|
||||
@@ -2596,9 +2574,8 @@ fn wrap_polygons_round_trip() {
|
||||
}
|
||||
d.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "closed: {xml}");
|
||||
let r = unstacked(wordcraft_docx::read(&bytes).expect("read"));
|
||||
@@ -2635,9 +2612,8 @@ fn enclosed_characters_round_trip_in_words_structure() {
|
||||
}
|
||||
let doc = doc_with(vec![p]);
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"<w:instrText xml:space="preserve"> eq \o\ac(○</w:instrText>"#), "{xml}");
|
||||
assert!(!xml.contains("separate") && !xml.contains("enlarge"), "{xml}");
|
||||
// Shrink text at 11 pt: the character at 7.5 pt (half-points 15), raised.
|
||||
@@ -2670,9 +2646,8 @@ fn ruby_phonetic_guide_round_trips_as_w_ruby() {
|
||||
p.insert_object(5, ruby.clone(), &bold).unwrap();
|
||||
let d = doc_with(vec![p]);
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
for part in [
|
||||
r#"<w:rubyAlign w:val="distributeSpace"/><w:hps w:val="11"/><w:hpsRaise w:val="20"/><w:hpsBaseText w:val="22"/><w:lid w:val="ja-JP"/>"#,
|
||||
"<w:rt><w:r>",
|
||||
@@ -2717,9 +2692,8 @@ fn wrap_text_sides_round_trip() {
|
||||
}
|
||||
d.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
for tag in [
|
||||
r#"<wp:wrapSquare wrapText="left"/>"#,
|
||||
r#"<wp:wrapTight wrapText="right">"#,
|
||||
@@ -2784,9 +2758,8 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
|
||||
t.props.description = Some("Prices by year".into());
|
||||
d.body = vec![para_block(p), Arc::new(Block::Table(t))];
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"{C183D7F6-B498-43B3-948B-1728B52AA6E4}"><adec:decorative xmlns:adec="http://schemas.microsoft.com/office/drawing/2017/decorative" val="1"/>"#), "{xml}");
|
||||
assert!(
|
||||
xml.contains(r#"<w:tblLook "#) && xml.contains(r#"<w:tblCaption w:val="Prices"/><w:tblDescription w:val="Prices by year"/></w:tblPr>"#),
|
||||
@@ -2865,9 +2838,8 @@ fn warps_and_glued_connectors_round_trip() {
|
||||
|
||||
// Hostile: an unknown warp, a huge adjust value, a glue to a missing shape.
|
||||
let xml = wordcraft_docx::write(&r).unwrap();
|
||||
let mut pkg = zip::ZipArchive::new(std::io::Cursor::new(xml)).unwrap();
|
||||
let mut doc_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut pkg.by_name("word/document.xml").unwrap(), &mut doc_xml).unwrap();
|
||||
let mut pkg = craft_zip::Archive::open(&xml, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let doc_xml = String::from_utf8(pkg.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let doc_xml = doc_xml
|
||||
.replace("prst=\"textArchUp\"", "prst=\"textNotAWarp\"")
|
||||
.replace("a:stCxn id=\"5\" idx=\"3\"", "a:stCxn id=\"404\" idx=\"99999999999\"");
|
||||
@@ -2880,29 +2852,29 @@ fn warps_and_glued_connectors_round_trip() {
|
||||
|
||||
/// `zip` with part `name` replaced by `data`.
|
||||
fn replace_part(zip_bytes: &[u8], name: &str, data: &[u8]) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).unwrap();
|
||||
let n = f.name().to_string();
|
||||
out.start_file(n.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
|
||||
let mut src = craft_zip::Archive::open(zip_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = src.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = craft_zip::Writer::new();
|
||||
for n in names {
|
||||
if n == name {
|
||||
std::io::Write::write_all(&mut out, data).unwrap();
|
||||
out.add(&n, data, craft_zip::Compression::Deflated).unwrap();
|
||||
} else {
|
||||
std::io::copy(&mut f, &mut out).unwrap();
|
||||
let bytes = src.read(&n, 16 << 20).unwrap().unwrap();
|
||||
out.add(&n, &bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
out.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A texture or picture page background (`v:background/v:fill`) survives a save (#999): the image
|
||||
/// part, its relationship and the fill attributes.
|
||||
#[test]
|
||||
fn page_background_picture_round_trips() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 150, 100, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let png = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 150, 100, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
let mut d = Document::new();
|
||||
d.settings.page_color = Some(Rgb(0xFF, 0xFF, 0xCC));
|
||||
d.media.insert("paper.png".into(), Arc::new(png.clone()));
|
||||
@@ -2932,9 +2904,8 @@ fn drop_cap_options_round_trip() {
|
||||
margin.props.drop_cap_margin = Some(true);
|
||||
let d = doc_with(vec![dropped, margin]);
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(xml.contains(r#"w:dropCap="drop" w:lines="2" w:hSpace="120""#), "{xml}");
|
||||
assert!(xml.contains(r#"w:dropCap="margin" w:lines="4""#), "{xml}");
|
||||
assert!(xml.contains(r#"w:ascii="Liberation Mono""#), "the letter's font on its run: {xml}");
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
//! Row-level table property exceptions (`w:tblPrEx` in `w:tr`, ECMA-376 Part 1 §17.4.61).
|
||||
//! Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn row(ex: &str, text: &str) -> String {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Run and style properties that must survive repeated DOCX saves.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str, styles: &str) -> Vec<u8> {
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
@@ -9,24 +7,22 @@ fn docx(body: &str, styles: &str) -> Vec<u8> {
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!(r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{styles}</w:styles>"#);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, xml) in [
|
||||
("_rels/.rels", root.to_string()),
|
||||
("word/document.xml", document),
|
||||
("word/_rels/document.xml.rels", rels.to_string()),
|
||||
("word/styles.xml", styles),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(xml.as_bytes()).unwrap();
|
||||
zip.add(name, xml.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
let mut zip = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(zip.read(name, 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
//! Section form protection (`w:formProt` in `w:sectPr`, ECMA-376 Part 1 §17.6.6). Fixtures are
|
||||
//! hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Section line numbering (`w:lnNumType` in `w:sectPr`, ECMA-376 Part 1 §17.6.8).
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::section::{LineNumberRestart, LineNumbering};
|
||||
|
||||
/// Builds a minimal in-memory DOCX package archive containing the given XML body.
|
||||
@@ -10,20 +8,18 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// Extracts the `word/document.xml` content string from a serialized DOCX package.
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// #6209: `<w:lnNumType>` without `w:countBy` leaves line numbering off, as Word does.
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
//! Section page-number settings (`w:pgNumType` in `w:sectPr`, ECMA-376 Part 1 §17.6.12).
|
||||
//! Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Section text direction (`w:textDirection` in `w:sectPr`, ECMA-376 Part 1 §17.6.20). Fixtures
|
||||
//! are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::TextDirection;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
@@ -10,16 +8,15 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut bytes = craft_zip::Writer::new();
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
@@ -31,17 +28,15 @@ fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/styles.xml", styles.as_bytes()),
|
||||
] {
|
||||
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
bytes.write_all(value).unwrap();
|
||||
bytes.add(name, value, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
bytes.finish().unwrap().into_inner()
|
||||
bytes.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,8 +117,8 @@ fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
|
||||
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
|
||||
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
|
||||
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut styles_xml = String::new();
|
||||
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
|
||||
let _z_bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let styles_xml = String::from_utf8(z.read("word/styles.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Section vertical alignment (`w:vAlign` in `w:sectPr`, ECMA-376 Part 1 §17.6.23, values from
|
||||
//! ST_VerticalJc). Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::section::PageVAlign;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
@@ -10,19 +8,17 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// #1320: "both" (Justified) reads as Justify and writes back as "both"; the others keep theirs.
|
||||
|
||||
@@ -4,29 +4,26 @@
|
||||
//! fixtures (from the spec), no files produced by Word.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn zip(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).unwrap();
|
||||
assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name());
|
||||
for name in names {
|
||||
let b = z.read(&name, 16 << 20).unwrap().unwrap();
|
||||
assert!(out.insert(name.clone(), b).is_none(), "duplicate part {name}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
|
||||
//! drawing is written once and filled in with either set of namespaces.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::{Document, InlineObject, PartKind};
|
||||
|
||||
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
|
||||
@@ -33,19 +31,17 @@ const TRANSITIONAL: Names =
|
||||
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn solid(rgb: &str) -> String {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
|
||||
//! moves every line after. Fixtures are hand-written from the spec.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::props::TextAlignment;
|
||||
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
@@ -12,19 +10,17 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,26 +2,22 @@
|
||||
//! `wps:txbx/@id`, `wps:linkedTxbx`). Fixtures are hand-written from ECMA-376 Part 1 §20.4
|
||||
//! (DrawingML WordprocessingML drawing) and §21.1.2.1.1 (`bodyPr`).
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
use wordcraft_doc::props::{TextVert, VAlign};
|
||||
use wordcraft_doc::{Document, InlineObject, PartKind, TextBody, para_block};
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The text boxes' stories in body order, a group's members among them
|
||||
@@ -217,7 +213,15 @@ fn picture_in_box_keeps_its_frame() {
|
||||
let outer = text_box(&mut d, "Label", body);
|
||||
let id = outer.text_box().unwrap();
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::from_pixel(10, 4, image::Rgba([20, 40, 60, 255])).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
std::io::Write::write_all(
|
||||
&mut bytes,
|
||||
&wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(10, 4, image::Rgba([20, 40, 60, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let media = d.add_media(bytes.into_inner(), "png");
|
||||
let image = InlineObject::Image {
|
||||
media: media.clone(),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Original field-boundary fixtures from the public OOXML complex-field contract.
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{InlineObject, Path, StoryRef};
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let xml = format!(r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, value) in [
|
||||
("word/document.xml", xml.as_str()),
|
||||
(
|
||||
@@ -13,10 +12,9 @@ fn package(body: &str) -> Vec<u8> {
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
zip.add(name, value.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
fn start(code: &str) -> String {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! A paragraph whose `w:pStyle` names a style `styles.xml` doesn't define is formatted as Normal,
|
||||
//! as in Word, not with the document defaults alone (#5077). Pandoc writes footers as
|
||||
//! `<w:pStyle w:val="Footer"/>` without defining Footer.
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
use wordcraft_doc::{Block, Document, PartKind};
|
||||
|
||||
@@ -20,7 +19,7 @@ fn package(normal_id: &str) -> Vec<u8> {
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}"><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="24"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="200"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="{normal_id}"><w:name w:val="Normal"/><w:pPr><w:spacing w:before="20" w:after="40" w:line="259" w:lineRule="auto"/></w:pPr><w:rPr><w:sz w:val="26"/></w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, body) in [
|
||||
("_rels/.rels", root),
|
||||
("word/document.xml", doc.as_str()),
|
||||
@@ -28,10 +27,9 @@ fn package(normal_id: &str) -> Vec<u8> {
|
||||
("word/styles.xml", styles.as_str()),
|
||||
("word/footer1.xml", footer.as_str()),
|
||||
] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
zw.add(name, body.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn footer_para(d: &Document) -> &wordcraft_doc::Paragraph {
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
//! `coordsize`/`coordorigin` give them. Fixtures are hand-written from ECMA-376 Part 4
|
||||
//! (VML, §14.1 `v:group`, `coordsize`, `coordorigin`; §14.2 shapes).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::para::ShapeKind;
|
||||
use wordcraft_doc::{Document, InlineObject, Paragraph};
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A document whose body is `body`.
|
||||
|
||||
@@ -20,7 +20,11 @@ fn part_text(doc: &Document, id: Option<u32>) -> String {
|
||||
fn tiny_png_b64() -> String {
|
||||
let img = image::RgbaImage::from_fn(2, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 100, 50, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
std::io::Write::write_all(
|
||||
&mut out,
|
||||
&wordcraft_geom::picture::encode_format(&image::DynamicImage::from(img.clone()), image::ImageFormat::Png).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner());
|
||||
// Line breaks inside base64, as the formats write it.
|
||||
b64.as_bytes().chunks(20).map(|c| std::str::from_utf8(c).unwrap()).collect::<Vec<_>>().join("\n")
|
||||
|
||||
@@ -13,7 +13,11 @@ use wordcraft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003};
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
std::io::Write::write_all(
|
||||
&mut out,
|
||||
&wordcraft_geom::picture::encode_format(&image::DynamicImage::from(img.clone()), image::ImageFormat::Png).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ fn ragged_matrix_save_stays_small() {
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![para_block(p)];
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let size = zip.by_name("word/document.xml").unwrap().size();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let size = zip.declared_size("word/document.xml").unwrap();
|
||||
assert!(size < 1_000_000, "document.xml is {size} bytes");
|
||||
}
|
||||
|
||||
@@ -59,5 +59,5 @@ rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
encoding_rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
craft-zip = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,13 @@ use wordcraft_doc::{Path, Pos, StoryRef};
|
||||
use super::{delete_selection, find_pattern, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Browsers reserve Mod+Shift+C for their inspector. Keep the desktop binding, but
|
||||
/// advertise a web alternative through the same registry/keymap as every other command.
|
||||
/// Mod+Alt+C already inserts copyright, and Mod+Alt+Shift+V is Keep Text Only.
|
||||
fn copy_format_shortcut(web: bool) -> &'static str {
|
||||
if web { "Mod+Alt+Shift+C / Mod+Shift+C" } else { "Mod+Shift+C" }
|
||||
}
|
||||
|
||||
/// Undo several steps at once: the Undo button's list (#701). `count` is validated here and
|
||||
/// clamped to the undo depth, so hostile numbers never reach the stack.
|
||||
fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -168,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
painter_pick(s);
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+Shift+C")
|
||||
.key(copy_format_shortcut(cfg!(target_arch = "wasm32")))
|
||||
.pure(),
|
||||
CommandSpec::new("edit.pasteFormat", "Paste Formatting", "Home › Clipboard", paste_format).key("Mod+Shift+V"),
|
||||
]
|
||||
@@ -854,7 +861,10 @@ fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let f = s.sel.focus.clone();
|
||||
if let Some(pp) = s.doc.para_at(&f) {
|
||||
let (x, y) = pp.word_at(f.off);
|
||||
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
|
||||
// Word selection includes trailing spaces; a painter click formats just the word,
|
||||
// matching the ordinary collapsed-caret formatting target.
|
||||
let word_end = pp.text.get(x..y).map(|word| x + word.trim_end().len()).unwrap_or(y);
|
||||
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: word_end, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
|
||||
}
|
||||
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
|
||||
} else {
|
||||
@@ -872,3 +882,49 @@ fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shortcut_tests {
|
||||
use super::*;
|
||||
use crate::{KeyMap, Registry};
|
||||
|
||||
#[test]
|
||||
fn copy_format_web_alias_is_unique_and_keeps_existing_shortcuts() {
|
||||
for web in [false, true] {
|
||||
let mut commands = super::super::registry().all().to_vec();
|
||||
let copy = commands.iter_mut().find(|c| c.id == "edit.copyFormat").unwrap();
|
||||
copy.shortcut = copy_format_shortcut(web);
|
||||
let registry = Registry::new(commands);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for command in registry.all() {
|
||||
for key in command.shortcut.split(" / ").filter_map(crate::keymap::normalize) {
|
||||
assert!(seen.insert(key.clone()), "duplicate binding: {key}");
|
||||
}
|
||||
}
|
||||
let keys = KeyMap::default();
|
||||
let resolve = |key| keys.resolve(®istry, key).map(|c| c.id);
|
||||
assert_eq!(resolve("Mod+Shift+C"), Some("edit.copyFormat"));
|
||||
assert_eq!(resolve("Mod+Alt+Shift+C"), web.then_some("edit.copyFormat"));
|
||||
assert_eq!(resolve("Mod+Shift+V"), Some("edit.pasteFormat"));
|
||||
assert_eq!(resolve("Mod+Alt+Shift+V"), Some("edit.pasteText"));
|
||||
if !cfg!(target_os = "macos") {
|
||||
assert_eq!(resolve("Mod+Alt+C"), Some("text.copyright"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_format_web_alias_respects_user_removal_and_reassignment() {
|
||||
let mut commands = super::super::registry().all().to_vec();
|
||||
commands.iter_mut().find(|c| c.id == "edit.copyFormat").unwrap().shortcut = copy_format_shortcut(true);
|
||||
let registry = Registry::new(commands);
|
||||
let mut keys = KeyMap::default();
|
||||
assert_eq!(keys.keys_for(®istry, "edit.copyFormat").first().map(String::as_str), Some("Mod+Alt+Shift+C"));
|
||||
assert!(keys.remove(®istry, "edit.copyFormat", "Mod+Alt+Shift+C").unwrap());
|
||||
assert!(keys.resolve(®istry, "Mod+Alt+Shift+C").is_none());
|
||||
keys.assign(®istry, "format.bold", "Mod+Alt+Shift+C").unwrap();
|
||||
let restored = KeyMap::from_value(&serde_json::to_value(&keys).unwrap());
|
||||
assert_eq!(restored.resolve(®istry, "Mod+Alt+Shift+C").map(|c| c.id), Some("format.bold"));
|
||||
assert_eq!(restored.resolve(®istry, "Mod+Shift+C").map(|c| c.id), Some("edit.copyFormat"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ fn gallery_lists_everything() {
|
||||
|
||||
/// A session on a DOCX whose only paragraph is `<m:oMath>{math}</m:oMath>`.
|
||||
fn imported(math: &str) -> Session {
|
||||
use std::io::Write;
|
||||
let w = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
let m = "http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
let parts = [
|
||||
@@ -117,24 +116,21 @@ fn imported(math: &str) -> Session {
|
||||
),
|
||||
("word/document.xml", format!(r#"<w:document xmlns:w="{w}" xmlns:m="{m}"><w:body><w:p><m:oMath>{math}</m:oMath></w:p><w:sectPr/></w:body></w:document>"#)),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, xml) in parts {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
zw.add(name, xml.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let bytes = zw.finish().unwrap().into_inner();
|
||||
let bytes = zw.finish().unwrap();
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
s.run("equation.edit", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn document_xml(s: &Session) -> String {
|
||||
use std::io::Read;
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,7 @@ use super::*;
|
||||
#[test]
|
||||
fn a_huge_picture_width_keeps_the_size_finite() {
|
||||
let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let png = wordcraft_geom::picture::encode_format(&image::DynamicImage::ImageRgba8(img).clone(), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap();
|
||||
let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap());
|
||||
@@ -17,8 +16,8 @@ fn a_huge_picture_width_keeps_the_size_finite() {
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut t), image::ImageFormat::Tiff).unwrap();
|
||||
let mut t =
|
||||
wordcraft_geom::picture::encode_format(&image::DynamicImage::from(image::RgbImage::new(4, 4).clone()), image::ImageFormat::Tiff).unwrap();
|
||||
assert_eq!(&t[..4], b"II*\0");
|
||||
let ifd = u32::from_le_bytes(t[4..8].try_into().unwrap()) as usize;
|
||||
let n = usize::from(u16::from_le_bytes([t[ifd], t[ifd + 1]]));
|
||||
@@ -37,8 +36,8 @@ fn tiffs_with_jpeg_strips_are_refused() {
|
||||
let e = s.run("insert.picture", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
assert!(s.doc.media.is_empty());
|
||||
let mut plain = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Tiff).unwrap();
|
||||
let plain =
|
||||
wordcraft_geom::picture::encode_format(&image::DynamicImage::from(image::RgbImage::new(4, 4).clone()), image::ImageFormat::Tiff).unwrap();
|
||||
s.run("insert.picture", &json!({"data": base64_encode(&plain)})).unwrap();
|
||||
let e = s.run("picture.change", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
|
||||
@@ -189,22 +189,19 @@ fn merge_end_to_end() {
|
||||
/// A typed list (#240) keeps its column order, and rows may be arrays in that order.
|
||||
#[test]
|
||||
fn recipients_from_a_workbook_ask_for_the_sheet_when_there_are_several() {
|
||||
use std::io::Write;
|
||||
let cell = |t: &str| format!(r#"<table:table-cell office:value-type="string"><text:p>{t}</text:p></table:table-cell>"#);
|
||||
let row = |cells: &[&str]| format!("<table:table-row>{}</table:table-row>", cells.iter().map(|c| cell(c)).collect::<String>());
|
||||
let content = format!(
|
||||
r#"<office:document-content xmlns:office="o" xmlns:table="t" xmlns:text="x"><office:body><office:spreadsheet><table:table table:name="Notes">{}</table:table><table:table table:name="Guests">{}{}{}</table:table></office:spreadsheet></office:body></office:document-content>"#,
|
||||
r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:spreadsheet><table:table table:name="Notes">{}</table:table><table:table table:name="Guests">{}{}{}</table:table></office:spreadsheet></office:body></office:document-content>"#,
|
||||
row(&["n"]),
|
||||
row(&["Name", "", "Name"]),
|
||||
row(&["Ada", "London", "A"]),
|
||||
row(&["Alan", "Wilmslow", "B"]),
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in [("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
let data = super::super::insert::base64_encode(&z.finish().unwrap().into_inner());
|
||||
let mut z = craft_zip::Writer::new();
|
||||
z.add("mimetype", b"application/vnd.oasis.opendocument.spreadsheet", craft_zip::Compression::Stored).unwrap();
|
||||
z.add("content.xml", content.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
let data = super::super::insert::base64_encode(&z.finish().unwrap());
|
||||
let mut s = Session::new(Document::new());
|
||||
// Two sheets and none chosen: the names come back and nothing loads.
|
||||
let r = s.run("mailings.recipients", &json!({"data": data})).unwrap();
|
||||
@@ -593,8 +590,7 @@ fn recipients_from_an_xls_workbook() {
|
||||
let book = wordcraft_formats::sheet::xls_fixture(&[(
|
||||
"收件人",
|
||||
vec![row(&["姓名", "城市", "数量"]), row(&["张三", "北京", "12"]), row(&["李四", "上海", "2"])],
|
||||
)])
|
||||
.unwrap();
|
||||
)]);
|
||||
let mut s = Session::new(Document::new());
|
||||
let r = s.run("mailings.recipients", &json!({"path": "名单.xls", "data": b64(&book)})).unwrap();
|
||||
assert_eq!(r, json!({"fields": ["姓名", "城市", "数量"], "records": 2, "sheet": "收件人"}));
|
||||
|
||||
@@ -194,8 +194,7 @@ pub(super) fn adjust_media(
|
||||
e => CmdError::Failed(format!("can't decode the picture: {e}")),
|
||||
})?;
|
||||
let out = f(img)?;
|
||||
let mut png = Vec::new();
|
||||
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let png = wordcraft_geom::picture::encode_png(&out).map_err(CmdError::Failed)?;
|
||||
let key = s.doc.add_media(png, "png");
|
||||
// Chain onto the ultimate original (the base's, or the current picture's when the base
|
||||
// is itself derived), so Reset Picture always finds it.
|
||||
|
||||
@@ -5,9 +5,8 @@ fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
if x > 2 && x < w - 3 && y > 2 && y < h - 3 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([255, 255, 255, 255]) }
|
||||
});
|
||||
let mut b = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png).unwrap();
|
||||
b
|
||||
|
||||
wordcraft_geom::picture::encode_format(&image::DynamicImage::ImageRgba8(img).clone(), image::ImageFormat::Png).unwrap()
|
||||
}
|
||||
/// The laid-out area of the only picture or shape (not a text box).
|
||||
fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
|
||||
@@ -451,8 +450,9 @@ fn remove_background_keeps_a_small_stack() {
|
||||
/// 1 × 2,000,000 picture Thick Frame asked for about 1.8 TB and aborted the process.
|
||||
#[test]
|
||||
fn picture_styles_and_borders_check_the_size_they_draw() {
|
||||
let mut thin = Vec::new();
|
||||
image::GrayImage::new(1, 2_000_000).write_to(&mut std::io::Cursor::new(&mut thin), image::ImageFormat::Png).unwrap();
|
||||
let thin =
|
||||
wordcraft_geom::picture::encode_format(&image::DynamicImage::from(image::GrayImage::new(1, 2_000_000).clone()), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&thin)})).unwrap();
|
||||
let media = s.doc.media.len();
|
||||
@@ -526,7 +526,7 @@ fn transparency_is_absolute_from_the_original() {
|
||||
let (_, o) = selected(s).unwrap();
|
||||
let InlineObject::Image { media, .. } = o else { panic!("a picture") };
|
||||
let bytes = s.doc.media.get(&media).cloned().unwrap();
|
||||
image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>()
|
||||
wordcraft_geom::picture::decode_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>()
|
||||
};
|
||||
let alpha = |s: &Session| rgba(s).iter().map(|p| p[3]).collect::<Vec<_>>();
|
||||
let orig = rgba(&s);
|
||||
@@ -579,7 +579,7 @@ fn picture_edits_replace_the_file_corrections() {
|
||||
let (_, o) = selected(s).unwrap();
|
||||
let InlineObject::Image { media, fx, .. } = o else { panic!("a picture") };
|
||||
let bytes = s.doc.media.get(&media).cloned().unwrap();
|
||||
(media, fx, image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
|
||||
(media, fx, wordcraft_geom::picture::decode_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
|
||||
};
|
||||
let (orig_media, _, orig) = state(&s);
|
||||
// Transparency 50%: 50% (not 75%), the file's transparency dropped, its lum kept.
|
||||
@@ -670,7 +670,7 @@ fn picture_styles_borders_and_effects_replace_the_last_one() {
|
||||
let bitmap = |s: &Session| {
|
||||
let (_, o) = selected(s).unwrap();
|
||||
let InlineObject::Image { media, .. } = o else { panic!("a picture") };
|
||||
image::load_from_memory(&s.doc.media.get(&media).cloned().unwrap()).unwrap().to_rgba8()
|
||||
wordcraft_geom::picture::decode_memory(&s.doc.media.get(&media).cloned().unwrap()).unwrap().to_rgba8()
|
||||
};
|
||||
let after = |cmds: &[(&str, Value)]| {
|
||||
let mut s = picture();
|
||||
@@ -1274,9 +1274,9 @@ fn arrange_bounds_on_header_picture_writes_page_anchored_odt_frame() {
|
||||
assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page));
|
||||
assert_eq!((float.x, float.y), (0.0, 0.0));
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
|
||||
let mut styles = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("styles.xml").unwrap(), &mut styles).unwrap();
|
||||
let _z_bytes = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let styles = String::from_utf8(z.read("styles.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(styles.contains(r#"text:anchor-type="page""#), "frame should be page-anchored: {styles}");
|
||||
assert!(styles.contains(r#"svg:x="0pt""#) && styles.contains(r#"svg:y="0pt""#), "offsets should be 0pt: {styles}");
|
||||
assert!(!styles.contains(r#"text:anchor-type="paragraph""#), "frame should not be paragraph-anchored: {styles}");
|
||||
@@ -1288,9 +1288,9 @@ fn arrange_bounds_on_header_picture_writes_page_anchored_odt_frame() {
|
||||
assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page));
|
||||
assert_eq!((float.x, float.y), (10.0, 20.0));
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
|
||||
let mut styles = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("styles.xml").unwrap(), &mut styles).unwrap();
|
||||
let _z_bytes = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let styles = String::from_utf8(z.read("styles.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(styles.contains(r#"text:anchor-type="page""#), "{styles}");
|
||||
assert!(styles.contains(r#"svg:x="10pt""#) && styles.contains(r#"svg:y="20pt""#), "{styles}");
|
||||
}
|
||||
@@ -1325,9 +1325,9 @@ fn a_moved_body_picture_is_placed_by_its_paragraph_even_when_fixed_to_the_page()
|
||||
let hit = s.layout().object(&pos, 0).expect("laid out");
|
||||
assert!((hit.rect.x - 50.0).abs() < 1.0 && (hit.rect.y - 80.0).abs() < 1.0, "dropped: {:?}", hit.rect);
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
|
||||
let mut content = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap();
|
||||
let _z_bytes = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let content = String::from_utf8(z.read("content.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(content.contains(r#"text:anchor-type="paragraph""#), "body picture should export with paragraph anchor: {content}");
|
||||
assert!(!content.contains("anchor-page-number"), "{content}");
|
||||
}
|
||||
@@ -1606,7 +1606,6 @@ fn mirrored_indents_make_no_extra_column() {
|
||||
/// A Word file whose only paragraph holds a group of a rectangle and Word's own ink (with its
|
||||
/// picture fallback).
|
||||
fn group_with_word_ink() -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk""#;
|
||||
let rect = r#"<wps:wsp><wps:cNvPr id="2" name="Box"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>"#;
|
||||
let ink = r#"<mc:AlternateContent><mc:Choice Requires="wpi"><w14:contentPart bwMode="auto" r:id="rInk"><w14:nvContentPartPr><w14:cNvPr id="3" name="Ink 3"/><w14:cNvContentPartPr/></w14:nvContentPartPr><w14:xfrm><a:off x="914400" y="0"/><a:ext cx="914400" cy="457200"/></w14:xfrm></w14:contentPart></mc:Choice><mc:Fallback><pic:pic><pic:nvPicPr><pic:cNvPr id="3" name="Ink 3"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rPreview"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="914400" y="0"/><a:ext cx="914400" cy="457200"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></mc:Fallback></mc:AlternateContent>"#;
|
||||
@@ -1626,14 +1625,12 @@ fn group_with_word_ink() -> Vec<u8> {
|
||||
),
|
||||
("word/ink/ink1.xml", r#"<ink xmlns="http://www.w3.org/2003/InkML"><trace>0 0, 1 1</trace></ink>"#.to_string()),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, xml) in parts {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
zw.add(name, xml.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.start_file("word/media/preview.png", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&png(8, 4)).unwrap();
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.add("word/media/preview.png", &png(8, 4), craft_zip::Compression::Deflated).unwrap();
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
fn ink_members(doc: &wordcraft_doc::Document) -> usize {
|
||||
@@ -1653,7 +1650,6 @@ fn ink_members(doc: &wordcraft_doc::Document) -> usize {
|
||||
/// yet), so open → Ungroup → save → reopen keeps the ink in the group with its part.
|
||||
#[test]
|
||||
fn ungroup_refuses_a_group_holding_word_ink_and_the_ink_survives_saving() {
|
||||
use std::io::Read;
|
||||
let mut s = Session::new(wordcraft_docx::read(&group_with_word_ink()).unwrap());
|
||||
assert_eq!(ink_members(&s.doc), 1, "the fixture's ink is read into the group");
|
||||
let pos = Pos::body(0, 0);
|
||||
@@ -1666,16 +1662,14 @@ fn ungroup_refuses_a_group_holding_word_ink_and_the_ink_survives_saving() {
|
||||
let err = ungroup(&mut s, &json!({})).unwrap_err();
|
||||
assert!(matches!(err, CmdError::Disabled(_)), "{err:?}");
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(xml.matches("<w14:contentPart").count(), 1, "{xml}");
|
||||
assert!(xml.contains("<mc:Fallback>"), "{xml}");
|
||||
assert_eq!(bytes, before, "the refused ungroup leaves the document unchanged");
|
||||
let names: Vec<String> = z.file_names().map(String::from).collect();
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
assert_eq!(names.iter().filter(|n| n.starts_with("word/ink/")).count(), 1, "{names:?}");
|
||||
let mut rels = String::new();
|
||||
z.by_name("word/_rels/document.xml.rels").unwrap().read_to_string(&mut rels).unwrap();
|
||||
let rels = String::from_utf8(z.read("word/_rels/document.xml.rels", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(rels.matches("/customXml\"").count(), 1, "one ink relationship: {rels}");
|
||||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(ink_members(&back), 1);
|
||||
|
||||
@@ -56,7 +56,7 @@ fn docx_styleref_header_shows_each_page_s_chapter() {
|
||||
);
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, text) in [
|
||||
("_rels/.rels", root),
|
||||
("word/document.xml", body.as_str()),
|
||||
@@ -64,10 +64,9 @@ fn docx_styleref_header_shows_each_page_s_chapter() {
|
||||
("word/header1.xml", header.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
zw.add(name, text.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let bytes = zw.finish().unwrap().into_inner();
|
||||
let bytes = zw.finish().unwrap();
|
||||
let headers = |s: &mut Session| -> Vec<String> {
|
||||
s.layout()
|
||||
.pages
|
||||
@@ -714,12 +713,11 @@ fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
|
||||
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
|
||||
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
zw.add(name, text.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
|
||||
// The SEQ and REF results in reading order, text boxes where they are anchored.
|
||||
let mut results = Vec::new();
|
||||
@@ -774,12 +772,11 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
("word/endnotes.xml", endnotes),
|
||||
("word/comments.xml", comments),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, text) in &files {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
zw.add(name, text.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
|
||||
let mut results = Vec::new();
|
||||
for path in out.para_paths(StoryRef::Body) {
|
||||
|
||||
@@ -28,7 +28,7 @@ const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGB
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
|
||||
CommandSpec::new("insert.smartArt", "Diagram", "Insert › Illustrations", insert).params(
|
||||
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
|
||||
),
|
||||
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let below = p::bool(v, "below").unwrap_or(false);
|
||||
edit(s, move |g| {
|
||||
if g.items.len() >= MAX_ITEMS {
|
||||
return Err(CmdError::Failed(format!("a SmartArt graphic holds at most {MAX_ITEMS} items")));
|
||||
return Err(CmdError::Failed(format!("a diagram holds at most {MAX_ITEMS} items")));
|
||||
}
|
||||
let Some(last) = g.items.len().checked_sub(1) else {
|
||||
g.items.push(SmartArtItem::new(text.clone(), 0));
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"layout": "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn"}"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.colors", "Change Colors", "SmartArt Design › SmartArt Styles", |s, v| {
|
||||
CommandSpec::new("smartArt.colors", "Change Colors", "SmartArt Design › Diagram Styles", |s, v| {
|
||||
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
|
||||
edit(s, move |g| {
|
||||
g.colors = colors;
|
||||
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
|
||||
.when(has_smart_art),
|
||||
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
|
||||
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › Diagram Styles", |s, v| {
|
||||
let i = index(v, "index")?;
|
||||
let color = match v.get("color") {
|
||||
None | Some(Value::Null) => None,
|
||||
@@ -174,9 +174,9 @@ fn has_smart_art(s: &Session) -> Option<&'static str> {
|
||||
match selected(s) {
|
||||
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.smart_art.is_some() => None,
|
||||
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.kind == GraphicKind::Diagram => {
|
||||
Some("this SmartArt was made in another program: WordCraft shows it but can't edit it")
|
||||
Some("this diagram was made in another program: WordCraft shows it but can't edit it")
|
||||
}
|
||||
_ => Some("select a SmartArt graphic first"),
|
||||
_ => Some("select a diagram first"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,9 +299,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
/// Change the selected graphic's model with `f`, then lay it out again at its size.
|
||||
fn edit(s: &mut Session, f: impl Fn(&mut SmartArtSpec) -> Result<(), CmdError>) -> CmdResult {
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a SmartArt graphic first".into()))?;
|
||||
let InlineObject::Graphic { w, h, graphic, .. } = obj else { return Err(CmdError::Disabled("select a SmartArt graphic first".into())) };
|
||||
let mut spec = graphic.smart_art.as_deref().cloned().ok_or_else(|| CmdError::Disabled("this SmartArt graphic can't be edited".into()))?;
|
||||
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a diagram first".into()))?;
|
||||
let InlineObject::Graphic { w, h, graphic, .. } = obj else { return Err(CmdError::Disabled("select a diagram first".into())) };
|
||||
let mut spec = graphic.smart_art.as_deref().cloned().ok_or_else(|| CmdError::Disabled("this diagram can't be edited".into()))?;
|
||||
f(&mut spec)?;
|
||||
let next = Arc::new(smart_art_graphic(spec, &s.doc.settings.theme_colors, w, h));
|
||||
let out = json!({"smartArt": next.smart_art.as_deref()});
|
||||
|
||||
@@ -19,24 +19,24 @@ const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", insert).params(
|
||||
r#"{"text"?: string, "style"?: WordArt style id (see format.textEffects), "transform"?: "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|… (a DrawingML preset text warp)}"#,
|
||||
CommandSpec::new("insert.wordArt", "Decorative Text", "Insert › Text", insert).params(
|
||||
r#"{"text"?: string, "style"?: Decorative Text style id (see format.textEffects), "transform"?: "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|… (a DrawingML preset text warp)}"#,
|
||||
),
|
||||
CommandSpec::new("format.textEffects", "Text Effects", "Home › Font", text_effects).params(EFFECT_PARAMS),
|
||||
CommandSpec::new("wordArt.style", "WordArt Styles", "Shape Format › WordArt Styles", |s, v| {
|
||||
CommandSpec::new("wordArt.style", "Decorative Text Styles", "Shape Format › Decorative Text Styles", |s, v| {
|
||||
let id = p::req_str(v, "style")?;
|
||||
text_effects(s, &json!({"style": id}))
|
||||
})
|
||||
.params(r#"{"style": "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset"}"#),
|
||||
CommandSpec::new("wordArt.textFill", "Text Fill", "Shape Format › WordArt Styles", |s, v| {
|
||||
CommandSpec::new("wordArt.textFill", "Text Fill", "Shape Format › Decorative Text Styles", |s, v| {
|
||||
text_effects(s, &json!({"fill": v.get("fill").or_else(|| v.get("color")).cloned().unwrap_or(Value::Null)}))
|
||||
})
|
||||
.params(r#"{"fill": "RRGGBB"|"none"|{"gradient": ["RRGGBB", …], "angle"?: deg}|null}"#),
|
||||
CommandSpec::new("wordArt.textOutline", "Text Outline", "Shape Format › WordArt Styles", |s, v| {
|
||||
CommandSpec::new("wordArt.textOutline", "Text Outline", "Shape Format › Decorative Text Styles", |s, v| {
|
||||
text_effects(s, &json!({"outline": v.get("outline").cloned().unwrap_or_else(|| v.clone())}))
|
||||
})
|
||||
.params(r#"{"color"?: "RRGGBB"|null, "width"?: pt} or {"outline": "none"|null}"#),
|
||||
CommandSpec::new("wordArt.transform", "Transform", "Shape Format › WordArt Styles", transform)
|
||||
CommandSpec::new("wordArt.transform", "Transform", "Shape Format › Decorative Text Styles", transform)
|
||||
.params(r#"{"preset": "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|"textSlantUp"|"textSlantDown"|"textInflate"|"textDeflate"|"textChevron"|"textChevronInverted"|"textTriangle"|"textTriangleInverted"|… (any DrawingML preset text warp)|"none", "adj"?: n (the preset's adjust value)}"#),
|
||||
]
|
||||
}
|
||||
@@ -186,7 +186,7 @@ fn text_effects(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let theme = theme(s);
|
||||
let accent = theme.get(4).copied().unwrap_or(Rgb(0x15, 0x60, 0x82));
|
||||
let style = match p::str(v, "style") {
|
||||
Some(id) => Some(art_style(id, &theme).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{id}`")))?),
|
||||
Some(id) => Some(art_style(id, &theme).ok_or_else(|| CmdError::Params(format!("unknown decorative text style `{id}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let clear = p::bool(v, "clear").unwrap_or(false);
|
||||
@@ -244,11 +244,11 @@ fn transform(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(w)
|
||||
}
|
||||
};
|
||||
let pos = warp_target(s).ok_or_else(|| CmdError::Disabled("select a shape or WordArt first".into()))?;
|
||||
let pos = warp_target(s).ok_or_else(|| CmdError::Disabled("select a shape or decorative text first".into()))?;
|
||||
let para = s.doc.para_mut(pos.story, &pos.path)?;
|
||||
match para.object_at_mut(pos.off) {
|
||||
Some(InlineObject::Shape { extra, .. }) => extra.warp = warp.clone(),
|
||||
_ => return Err(CmdError::Disabled("select a shape or WordArt first".into())),
|
||||
_ => return Err(CmdError::Disabled("select a shape or decorative text first".into())),
|
||||
}
|
||||
para.touch();
|
||||
Ok(json!({"transform": warp.map(|w| w.preset)}))
|
||||
@@ -269,7 +269,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text: String = p::str(v, "text").unwrap_or("Your text here").chars().filter(|c| !c.is_control()).take(1000).collect();
|
||||
let text = if text.trim().is_empty() { "Your text here".to_string() } else { text };
|
||||
let style = p::str(v, "style").unwrap_or("solid");
|
||||
let fx = art_style(style, &theme(s)).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{style}`")))?;
|
||||
let fx = art_style(style, &theme(s)).ok_or_else(|| CmdError::Params(format!("unknown decorative text style `{style}`")))?;
|
||||
let warp = match p::str(v, "transform") {
|
||||
None | Some("none") => None,
|
||||
Some(id) => Some(TextWarp::new(id).ok_or_else(|| CmdError::Params(format!("unknown text warp `{id}`")))?),
|
||||
|
||||
@@ -468,7 +468,7 @@ pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Resu
|
||||
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||
let bytes = save_bytes_opts(name, doc, opts)?;
|
||||
match password {
|
||||
Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw),
|
||||
Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw).map_err(|e| e.to_string()),
|
||||
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
|
||||
_ => Ok(bytes),
|
||||
}
|
||||
|
||||
+109
-130
@@ -4271,7 +4271,6 @@ fn toc_menu_offers_update_field_after_save_and_reopen() {
|
||||
/// doesn't grow the document.
|
||||
#[test]
|
||||
fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_save() {
|
||||
use std::io::{Read, Write};
|
||||
let style = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
@@ -4289,7 +4288,7 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
|
||||
style("Heading1", &t("Chapter One")),
|
||||
style("Heading2", &t("Section"))
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, value) in [
|
||||
("word/document.xml", xml.as_str()),
|
||||
(
|
||||
@@ -4297,16 +4296,21 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
zip.add(name, value.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let mut s = Session::new(wordcraft_docx::read(&zip.finish().unwrap().into_inner()).unwrap());
|
||||
let mut s = Session::new(wordcraft_docx::read(&zip.finish().unwrap()).unwrap());
|
||||
let mut blocks = None;
|
||||
for round in 0..3 {
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let xml = String::from_utf8(
|
||||
craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 })
|
||||
.unwrap()
|
||||
.read("word/document.xml", 16 << 20)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "round {round}: {xml}");
|
||||
let (open, close) = (xml.find("<w:sdt>").unwrap(), xml.find("</w:sdt>").unwrap());
|
||||
assert!(open < xml.find(">Contents<").unwrap(), "round {round}: the heading is in the control: {xml}");
|
||||
@@ -4328,11 +4332,10 @@ fn style_xml(id: &str, name: &str, ppr: &str, rpr: &str) -> String {
|
||||
|
||||
/// A DOCX package with `body` and the paragraph `styles` after a default Normal style.
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
const RELS: &str = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
|
||||
const TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, value) in [
|
||||
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
|
||||
(
|
||||
@@ -4350,10 +4353,9 @@ fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
format!(r#"<Relationships {RELS}><Relationship Id="main" Type="{TYPE}/officeDocument" Target="word/document.xml"/></Relationships>"#),
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
zip.add(name, value.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
/// #3891: Update Table on a TOC Word wrote in Portuguese keeps the formatting of its TOC styles,
|
||||
@@ -4671,12 +4673,8 @@ fn update_toc_clears_stale_field_result_for_docx_span() {
|
||||
// DOCX round-trip: entries must be enclosed within the TOC field rather than outside it.
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let xml = {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).unwrap();
|
||||
let mut file = archive.by_name("word/document.xml").unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut file, &mut s).unwrap();
|
||||
s
|
||||
let mut archive = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
String::from_utf8(archive.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
};
|
||||
let b = xml.find(r#"w:fldCharType="begin""#).expect("TOC begin marker present");
|
||||
// The TOC's end is the last one: the entries' page numbers are `PAGEREF` fields inside it.
|
||||
@@ -4900,9 +4898,8 @@ fn toc_page_numbers_are_pageref_fields() {
|
||||
let expected = text(&s);
|
||||
assert!(expected.contains("Alpha\t2") && expected.contains("Bravo\t3"), "{expected}");
|
||||
let bytes = crate::io::save_bytes("toc.docx", &s.doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(xml.matches("PAGEREF _Toc").count(), 2, "{xml}");
|
||||
let mut s = Session::new(crate::io::open_bytes("toc.docx", &bytes).unwrap());
|
||||
run(&mut s, "references.updateFields", json!({}));
|
||||
@@ -5549,17 +5546,15 @@ fn an_odt_frame_moved_from_its_page_loses_the_page_number() {
|
||||
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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
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();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let odt = z.finish().unwrap().into_inner();
|
||||
let odt = z.finish().unwrap();
|
||||
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();
|
||||
let _a_bytes = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let mut a = craft_zip::Archive::open(&_a_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let c = String::from_utf8(a.read("content.xml", 16 << 20).unwrap().unwrap()).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());
|
||||
@@ -6033,10 +6028,11 @@ fn html_pictures_load_relative_to_the_file() {
|
||||
// Issue #98: `<img src="logo.png">` beside an HTML file is embedded when it is opened.
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-html-img-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("img")).unwrap();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([10, 20, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let png = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(4, 2, image::Rgba([10, 20, 30, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("img/my logo.png"), &png).unwrap();
|
||||
std::fs::write(dir.join("secret.txt"), b"not a picture").unwrap();
|
||||
let html = r#"<p><img src="img/my%20logo.png" alt="Logo"></p><p><img src="secret.txt" alt="T"></p><p><img src="http://example.com/x.png" alt="Web"></p>"#;
|
||||
@@ -6060,11 +6056,11 @@ fn scratch_dir(name: &str) -> std::path::PathBuf {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
png
|
||||
wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 30, 30, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -8276,7 +8272,7 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64(), r["icon"].as_str()), (Some(48.0), Some(48.0), Some("heart")));
|
||||
let Some((_, InlineObject::Image { media, w, h, alt, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
|
||||
assert_eq!((w, h, alt.as_str()), (48.0, 48.0, "Heart"));
|
||||
let png = image::load_from_memory(&s.doc.media[&media]).unwrap().to_rgba8();
|
||||
let png = wordcraft_geom::picture::decode_memory(&s.doc.media[&media]).unwrap().to_rgba8();
|
||||
assert_eq!(png.dimensions(), (200, 200), "about 300 pixels per inch");
|
||||
let inked: Vec<_> = png.pixels().filter(|p| p.0[3] == 255).collect();
|
||||
assert!(!inked.is_empty() && inked.iter().all(|p| p.0[..3] == [0xC0, 0, 0]));
|
||||
@@ -8288,7 +8284,7 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
|
||||
// Icon Color repaints it, keeping the transparency.
|
||||
run(&mut s, "picture.iconColor", json!({"color": "1F4E79"}));
|
||||
let Some((_, InlineObject::Image { media: m2, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
|
||||
let png2 = image::load_from_memory(&s.doc.media[&m2]).unwrap().to_rgba8();
|
||||
let png2 = wordcraft_geom::picture::decode_memory(&s.doc.media[&m2]).unwrap().to_rgba8();
|
||||
assert!(png2.pixels().zip(png.pixels()).all(|(a, b)| a.0[3] == b.0[3] && (a.0[3] == 0 || a.0[..3] == [0x1F, 0x4E, 0x79])));
|
||||
assert!(s.run("picture.iconColor", &json!({})).is_err());
|
||||
}
|
||||
@@ -9839,7 +9835,6 @@ fn english_dialects_survive_a_docx_round_trip() {
|
||||
if no_language_data("english_dialects_survive_a_docx_round_trip") {
|
||||
return;
|
||||
}
|
||||
use std::io::Read;
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Colour and color. Centre and center."}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
@@ -9849,12 +9844,8 @@ fn english_dialects_survive_a_docx_round_trip() {
|
||||
let before = spelling_with_first_suggestion(&mut s);
|
||||
assert_eq!(before, pairs(&[("color", "colour"), ("Centre", "Center")]));
|
||||
let bytes = crate::io::save_bytes("british.docx", &s.doc).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut part = |name: &str| {
|
||||
let mut xml = String::new();
|
||||
zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
};
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let mut part = |name: &str| String::from_utf8(zip.read(name, 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(part("word/styles.xml").contains(r#"w:val="en-GB""#), "the default language is written");
|
||||
assert!(part("word/document.xml").contains(r#"w:val="en-US""#), "the run's language is written");
|
||||
let doc = crate::io::open_bytes("british.docx", &bytes).unwrap();
|
||||
@@ -10335,7 +10326,6 @@ fn select_graphic(s: &mut Session) {
|
||||
/// reopens as the same editable graphic; an edited data part stays read-only.
|
||||
#[test]
|
||||
fn smart_art_round_trips_through_docx() {
|
||||
use std::io::Read;
|
||||
let mut s = s();
|
||||
let r = run(
|
||||
&mut s,
|
||||
@@ -10345,12 +10335,11 @@ fn smart_art_round_trips_through_docx() {
|
||||
assert_eq!(r["smartArt"]["layout"], "hierarchy");
|
||||
let spec = cmd::smart_art::selected_smart_art(&s).expect("selected after insert");
|
||||
let bytes = crate::io::save_bytes("sa.docx", &s.doc).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] {
|
||||
assert!(zip.by_name(&format!("word/diagrams/{part}.xml")).is_ok(), "{part} written");
|
||||
assert!(zip.contains(&format!("word/diagrams/{part}.xml")), "{part} written");
|
||||
}
|
||||
let mut ct = String::new();
|
||||
zip.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap();
|
||||
let ct = String::from_utf8(zip.read("[Content_Types].xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(ct.contains("diagramDrawing+xml") && ct.contains("diagramData+xml"), "{ct}");
|
||||
let mut s2 = Session::new(crate::io::open_bytes("sa.docx", &bytes).unwrap());
|
||||
select_graphic(&mut s2);
|
||||
@@ -10362,20 +10351,17 @@ fn smart_art_round_trips_through_docx() {
|
||||
select_graphic(&mut s3);
|
||||
assert_eq!(cmd::smart_art::selected_smart_art(&s3).map(|g| g.layout), Some(wordcraft_doc::smart_art::SmartArtLayout::Venn));
|
||||
// A data part another program rewrote: shown, not editable.
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&again)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut buf = Vec::new();
|
||||
f.read_to_end(&mut buf).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&again, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let names: Vec<String> = zip.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = craft_zip::Writer::new();
|
||||
for name in names {
|
||||
let mut buf = zip.read(&name, 16 << 20).unwrap().unwrap();
|
||||
if name.ends_with("data1.xml") {
|
||||
buf = String::from_utf8(buf).unwrap().replace("<dgm:bg/>", "<dgm:bg><a:noFill/></dgm:bg>").into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut out, &buf).unwrap();
|
||||
out.add(&name, &buf, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
let foreign = out.finish().unwrap().into_inner();
|
||||
let foreign = out.finish().unwrap();
|
||||
let mut s4 = Session::new(crate::io::open_bytes("sa.docx", &foreign).unwrap());
|
||||
select_graphic(&mut s4);
|
||||
assert!(cmd::smart_art::selected_smart_art(&s4).is_none());
|
||||
@@ -10641,10 +10627,11 @@ fn outline_many_collapsed_headings_stay_fast_after_edits() {
|
||||
#[test]
|
||||
fn imported_pictures_fit_the_text_width() {
|
||||
let png = |w: u32, h: u32| {
|
||||
let mut b = Vec::new();
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let b = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
cmd::insert::base64_encode(&b)
|
||||
};
|
||||
let sizes = |doc: &wordcraft_doc::Document| -> Vec<(f32, f32)> {
|
||||
@@ -11245,8 +11232,11 @@ fn picture_change_keeps_the_replacement_format() {
|
||||
let dir = scratch_dir("picture-change-format");
|
||||
let first = dir.join("first.png");
|
||||
std::fs::write(&first, tiny_png()).unwrap();
|
||||
let mut jpeg = Vec::new();
|
||||
image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
|
||||
let jpeg = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).clone()),
|
||||
image::ImageFormat::Jpeg,
|
||||
)
|
||||
.unwrap();
|
||||
let second = dir.join("second.jpg");
|
||||
std::fs::write(&second, &jpeg).unwrap();
|
||||
|
||||
@@ -11271,10 +11261,16 @@ fn picture_change_keeps_the_replacement_format() {
|
||||
#[test]
|
||||
fn picture_change_keeps_width_and_takes_new_aspect() {
|
||||
let dir = scratch_dir("picture-change-aspect");
|
||||
let mut wide = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
let mut tall = Vec::new();
|
||||
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
|
||||
let wide = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
let tall = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
|
||||
std::fs::write(&first, &wide).unwrap();
|
||||
std::fs::write(&second, &tall).unwrap();
|
||||
@@ -11407,7 +11403,6 @@ fn links_to_places_in_this_document() {
|
||||
/// formula field's result is edited (#1037). The first field's result is split across the field
|
||||
/// end; the second has its whole result deleted.
|
||||
fn tracked_field_result_docx() -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
const WHO: &str = r#"w:author="Tester" w:date="2026-10-12T00:00:00Z""#;
|
||||
let field = |instr: &str, inside: &str, after: &str| {
|
||||
format!(
|
||||
@@ -11421,7 +11416,7 @@ fn tracked_field_result_docx() -> Vec<u8> {
|
||||
let xml = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>{p1}</w:p><w:p>{p2}</w:p></w:body></w:document>"#
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, value) in [
|
||||
("word/document.xml", xml.as_str()),
|
||||
(
|
||||
@@ -11429,10 +11424,9 @@ fn tracked_field_result_docx() -> Vec<u8> {
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
zip.add(name, value.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -11446,9 +11440,8 @@ fn tracked_deletion_inside_a_field_result_is_a_revision_and_is_saved() {
|
||||
assert_eq!(p.text.replace(wordcraft_doc::para::OBJ, ""), "$150.00$140.00");
|
||||
// Saving keeps the deletion inside the result, with its text as delText.
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let sep = xml.find(r#"w:fldCharType="separate""#).unwrap();
|
||||
let end = xml.find(r#"w:fldCharType="end""#).unwrap();
|
||||
let inside = &xml[sep..end];
|
||||
@@ -12053,13 +12046,11 @@ fn opened_documents_with_their_own_page_size_keep_it() {
|
||||
fn html_converted_to_docx_keeps_bold_headers_and_right_aligned_cells() {
|
||||
// #1381: `wordcraft-cli convert S05.html out.docx`: the <th> runs are bold and the
|
||||
// `text-align:right` cells' paragraphs carry `w:jc w:val="right"`.
|
||||
use std::io::Read;
|
||||
let mut s = s();
|
||||
run(&mut s, "file.open", json!({"path": "S05.html", "data": crate::cmd::insert::base64_encode(SAMPLE_1381.as_bytes())}));
|
||||
let bytes = crate::io::save_bytes("out.docx", &s.doc).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let cell = |text: &str| {
|
||||
let at = xml.find(&format!(">{text}<")).unwrap_or_else(|| panic!("{text} in {xml}"));
|
||||
let start = xml[..at].rfind("<w:tc>").or_else(|| xml[..at].rfind("<w:tc ")).unwrap();
|
||||
@@ -12204,7 +12195,6 @@ fn typing_after_a_note_number_is_not_superscript() {
|
||||
/// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it.
|
||||
#[test]
|
||||
fn odt_inserted_then_deleted_text_round_trips() {
|
||||
use std::io::Write;
|
||||
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:dc="http://purl.org/dc/elements/1.1/""#;
|
||||
let info = |who: &str| format!("<office:change-info><dc:creator>{who}</dc:creator><dc:date>2024-03-01T10:00:00</dc:date></office:change-info>");
|
||||
let content = format!(
|
||||
@@ -12212,13 +12202,11 @@ fn odt_inserted_then_deleted_text_round_trips() {
|
||||
info("Alice"),
|
||||
info("Bob")
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
|
||||
for cmd in ["review.rejectAll", "review.acceptAll"] {
|
||||
let mut s = Session::new(doc.clone());
|
||||
@@ -12231,7 +12219,6 @@ fn odt_inserted_then_deleted_text_round_trips() {
|
||||
/// Reject All turns back into the original and Accept All into the edited text.
|
||||
#[test]
|
||||
fn odt_tracked_changes_accept_and_reject_all() {
|
||||
use std::io::Write;
|
||||
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:dc="http://purl.org/dc/elements/1.1/""#;
|
||||
let info = |who: &str| format!("<office:change-info><dc:creator>{who}</dc:creator><dc:date>2024-03-01T10:00:00</dc:date></office:change-info>");
|
||||
let content = format!(
|
||||
@@ -12239,13 +12226,11 @@ fn odt_tracked_changes_accept_and_reject_all() {
|
||||
info("Alice"),
|
||||
info("Bob")
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
let bytes = z.finish().unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &bytes).unwrap();
|
||||
|
||||
let mut sr = Session::new(doc.clone());
|
||||
@@ -12263,7 +12248,6 @@ fn odt_tracked_changes_accept_and_reject_all() {
|
||||
/// inserted, so Reject All keeps the two paragraphs (after an ODT save, too).
|
||||
#[test]
|
||||
fn odt_deleted_mark_inside_an_insertion_survives_reject_all() {
|
||||
use std::io::Write;
|
||||
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:dc="http://purl.org/dc/elements/1.1/""#;
|
||||
let info = |who: &str| format!("<office:change-info><dc:creator>{who}</dc:creator><dc:date>2024-03-01T10:00:00</dc:date></office:change-info>");
|
||||
let content = format!(
|
||||
@@ -12271,13 +12255,11 @@ fn odt_deleted_mark_inside_an_insertion_survives_reject_all() {
|
||||
info("Alice"),
|
||||
info("Bob")
|
||||
);
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
|
||||
let saved = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
|
||||
for d in [doc, saved] {
|
||||
let mut s = Session::new(d);
|
||||
@@ -13091,9 +13073,9 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
|
||||
run(&mut s, "insert.chart", json!({"type": "column"}));
|
||||
let first = wordcraft_formats::odt::export(&s.doc).unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &first).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
|
||||
let mut content = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap();
|
||||
let _z_bytes = wordcraft_formats::odt::export(&doc).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let content = String::from_utf8(z.read("content.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
|
||||
}
|
||||
|
||||
@@ -13109,18 +13091,16 @@ fn an_odts_picture_wider_than_the_text_keeps_its_size() {
|
||||
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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
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();
|
||||
z.add(name, data, craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
|
||||
let width = doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
@@ -13129,10 +13109,10 @@ fn an_odts_picture_wider_than_the_text_keeps_its_size() {
|
||||
.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}");
|
||||
let _z_bytes = wordcraft_formats::odt::export(&doc).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let saved = String::from_utf8(z.read("content.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert!(saved.contains(r#"svg:width="510.24pt""#), "written as read: {saved}");
|
||||
}
|
||||
|
||||
/// Numbering or a new style makes the styles be written from the model on save: a document in
|
||||
@@ -13146,14 +13126,12 @@ fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
|
||||
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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
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();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let odt = z.finish().unwrap().into_inner();
|
||||
let odt = z.finish().unwrap();
|
||||
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");
|
||||
@@ -13167,7 +13145,6 @@ fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
|
||||
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let files: [(String, Vec<u8>); 4] = [
|
||||
(
|
||||
"[Content_Types].xml".into(),
|
||||
@@ -13183,12 +13160,11 @@ fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> V
|
||||
format!(r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body}</w:body></w:document>"#).into_bytes(),
|
||||
),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853.
|
||||
@@ -13417,24 +13393,27 @@ fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
|
||||
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();
|
||||
std::io::Write::write_all(
|
||||
&mut png,
|
||||
&wordcraft_geom::picture::encode_format(&image::DynamicImage::from(image::RgbImage::new(1000, 500).clone()), image::ImageFormat::Png)
|
||||
.unwrap(),
|
||||
)
|
||||
.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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
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();
|
||||
z.add(name, data, craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
|
||||
doc.body[0]
|
||||
.as_para()
|
||||
.unwrap()
|
||||
|
||||
@@ -273,10 +273,15 @@ fn typing_kurdish_in_new_document_advances_caret_smoothly() {
|
||||
|
||||
/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text).
|
||||
fn saved_runs(s: &Session) -> Vec<(bool, String)> {
|
||||
use std::io::Read;
|
||||
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
let xml = String::from_utf8(
|
||||
craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 })
|
||||
.unwrap()
|
||||
.read("word/document.xml", 16 << 20)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let para = xml.split("<w:body>").nth(1).unwrap().split("</w:p>").next().unwrap().to_string();
|
||||
para.split("<w:r>")
|
||||
.skip(1)
|
||||
|
||||
@@ -149,12 +149,11 @@ fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec<
|
||||
}
|
||||
table.push_str("</w:font></w:fonts>");
|
||||
files.push(("word/fontTable.xml".into(), table.into_bytes()));
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, bytes) in files {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zip, &bytes).unwrap();
|
||||
zip.add(&name, &bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -200,7 +200,6 @@ fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() {
|
||||
/// up a level and then ends the list, as with direct numbering.
|
||||
#[test]
|
||||
fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
|
||||
use std::io::Write;
|
||||
let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
let style = |id: &str, name: &str, ilvl: u8| {
|
||||
format!(
|
||||
@@ -227,8 +226,7 @@ fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
|
||||
let doc_rels =
|
||||
format!("<Relationships {rels_ns}>{}{}</Relationships>", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml"));
|
||||
let ct = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/></Types>"#;
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [
|
||||
("[Content_Types].xml", ct),
|
||||
("_rels/.rels", main.as_str()),
|
||||
@@ -237,10 +235,9 @@ fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
|
||||
("word/styles.xml", styles.as_str()),
|
||||
("word/numbering.xml", numbering.as_str()),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap()).unwrap();
|
||||
let mut s = Session::new(doc);
|
||||
// The list level each paragraph shows, wherever its numbering comes from.
|
||||
let levels = |s: &Session| -> Vec<Option<u8>> {
|
||||
@@ -2433,12 +2430,11 @@ fn two_section_docx(kind: &str) -> Vec<u8> {
|
||||
),
|
||||
),
|
||||
];
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, data) in files {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
|
||||
zip.add(name, data.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as
|
||||
@@ -2504,12 +2500,11 @@ fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec<u8> {
|
||||
),
|
||||
),
|
||||
];
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zip = craft_zip::Writer::new();
|
||||
for (name, data) in files {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
|
||||
zip.add(name, data.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
/// #2127: Delete at the end of the last paragraph removes a section break on its mark too,
|
||||
|
||||
@@ -200,3 +200,12 @@ fn wordart_reflection_and_outline_none_options() {
|
||||
assert_eq!(outline.color, None);
|
||||
assert_eq!(outline.width, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_feature_labels_keep_existing_command_ids() {
|
||||
let s = Session::new(Document::new());
|
||||
for (id, label) in [("insert.smartArt", "Diagram"), ("insert.wordArt", "Decorative Text"), ("wordArt.style", "Decorative Text Styles")] {
|
||||
assert_eq!(s.registry.get(id).map(|spec| spec.label), Some(label));
|
||||
}
|
||||
assert!(s.registry.get("smartArt.colors").unwrap().location.starts_with("SmartArt Design › "));
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ use std::io::Write;
|
||||
use wordcraft_doc::{InlineObject, StoryRef};
|
||||
|
||||
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, body) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
zw.add(name, body.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// A field run: its code, its cached result, and whether it carries `w:fldLock`.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! macro-enabled document keeps its VBA project (as opaque bytes) when it is saved again.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_engine::io::{open_bytes, save_bytes};
|
||||
@@ -18,26 +17,24 @@ const REL_VBA_DATA: &str = "http://schemas.microsoft.com/office/2006/relationshi
|
||||
const VBA_DATA: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><wne:vbaSuppData xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wne:mcds><wne:mcd wne:macroName="PROJECT.NEWMACROS.HELLO" wne:name="Project.NewMacros.Hello" wne:bEncrypt="00" wne:cmg="56"/></wne:mcds></wne:vbaSuppData>"#;
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).expect("zip");
|
||||
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
|
||||
let mut out = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut b = Vec::new();
|
||||
f.read_to_end(&mut b).expect("read entry");
|
||||
out.insert(f.name().to_string(), b);
|
||||
for name in names {
|
||||
let b = z.read(&name, 16 << 20).expect("read entry").expect("entry");
|
||||
out.insert(name, b);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn zip(entries: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
// Content types first, as Word writes them.
|
||||
let order = entries.iter().filter(|(n, _)| *n == "[Content_Types].xml").chain(entries.iter().filter(|(n, _)| *n != "[Content_Types].xml"));
|
||||
for (name, bytes) in order {
|
||||
zw.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).expect("start");
|
||||
zw.write_all(bytes).expect("write");
|
||||
zw.add(name.as_str(), bytes, craft_zip::Compression::Deflated).expect("write");
|
||||
}
|
||||
zw.finish().expect("finish").into_inner()
|
||||
zw.finish().expect("finish")
|
||||
}
|
||||
|
||||
fn text(files: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! WordCraft draws and edits: Chart Design's commands change it like a chart made here. The ODT
|
||||
//! is original and synthetic, shaped like LibreOffice's.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::chart::ChartType;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
@@ -17,14 +15,12 @@ fn odt() -> Vec<u8> {
|
||||
);
|
||||
// A well-formed chart part, its prefixes declared as LibreOffice writes them.
|
||||
let chart = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"><office:body><office:chart><chart:chart chart:class="chart:line"><chart:title><text:p>Visits</text:p></chart:title><chart:plot-area><chart:axis chart:dimension="x"><chart:categories table:cell-range-address="local-table.$A$2:.$A$4"/></chart:axis><chart:series chart:values-cell-range-address="local-table.$B$2:.$B$4" chart:label-cell-address="local-table.$B$1"/></chart:plot-area><table:table table:name="local-table"><table:table-header-rows><table:table-row><table:table-cell/><table:table-cell office:value-type="string"><text:p>Site</text:p></table:table-cell></table:table-row></table:table-header-rows><table:table-rows><table:table-row><table:table-cell office:value-type="string"><text:p>Mon</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Tue</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="5"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Wed</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="4"/></table:table-row></table:table-rows></table:table></chart:chart></office:chart></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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("Object 1/content.xml", chart)]
|
||||
{
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
/// The document's charts: their type, title, and whether they're drawn.
|
||||
@@ -55,20 +51,18 @@ fn an_odt_chart_opens_drawn_and_editable() {
|
||||
fn a_chart_in_a_group_opens_drawn() {
|
||||
// The chart is a member of a drawing group (`draw:g`): it is drawn too.
|
||||
let odt = odt();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&odt)).unwrap();
|
||||
let chart = std::io::read_to_string(z.by_name("Object 1/content.xml").unwrap()).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&odt, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let chart = String::from_utf8(z.read("Object 1/content.xml", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS}><office:body><office:text><text:p>Group <draw:g text:anchor-type="as-char"><draw:frame svg:x="0in" svg:y="0in" svg:width="3in" svg:height="2in"><draw:object xlink:href="./Object 1"/></draw:frame><draw:rect svg:x="3in" svg:y="0in" svg:width="1in" svg:height="1in"/></draw:g></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let mut w = craft_zip::Writer::new();
|
||||
for (name, data) in
|
||||
[("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("Object 1/content.xml", chart.as_str())]
|
||||
{
|
||||
w.start_file(name, o).unwrap();
|
||||
w.write_all(data.as_bytes()).unwrap();
|
||||
w.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let doc = io::open_bytes("group.odt", &w.finish().unwrap().into_inner()).unwrap();
|
||||
let doc = io::open_bytes("group.odt", &w.finish().unwrap()).unwrap();
|
||||
let members: Vec<bool> = doc
|
||||
.body
|
||||
.iter()
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! the bookmarked text (a bookmark over text, `text:bookmark-start`…`-end`, not a point). The
|
||||
//! ODT is original and synthetic, shaped like LibreOffice's.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_engine::{Session, io};
|
||||
|
||||
@@ -11,13 +9,11 @@ const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1
|
||||
|
||||
fn odt(body: &str) -> Vec<u8> {
|
||||
let content = format!(r#"<office:document-content {NS}><office:body><office:text>{body}</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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn text(s: &Session) -> Vec<String> {
|
||||
@@ -87,13 +83,11 @@ fn a_flat_document_opens_and_saves_by_its_extension() {
|
||||
fn a_user_field_updates_from_its_property() {
|
||||
// An ODT user field opens as `DOCPROPERTY`: updating fields shows the property's value.
|
||||
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:user-field-decls><text:user-field-decl text:name="Owner" office:value-type="string" office:string-value="Ada"/></text:user-field-decls><text:p>By <text:user-field-get text:name="Owner">stale</text:user-field-get>.</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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
|
||||
z.start_file(name, o).unwrap();
|
||||
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let mut s = Session::new(io::open_bytes("user.odt", &z.finish().unwrap().into_inner()).unwrap());
|
||||
let mut s = Session::new(io::open_bytes("user.odt", &z.finish().unwrap()).unwrap());
|
||||
s.run("references.updateFields", &json!({"all": true})).unwrap();
|
||||
assert_eq!(results(&s), ["Ada"]);
|
||||
}
|
||||
|
||||
@@ -2,21 +2,17 @@
|
||||
//! stay what they are when the document is saved as DOCX (`w:sdt`) and as ODT again. The ODT is
|
||||
//! original and synthetic, shaped like LibreOffice's.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::control::ControlKind;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_engine::io;
|
||||
|
||||
fn odt() -> Vec<u8> {
|
||||
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"><office:body><office:text><office:forms><form:form form:name="Form"><form:listbox form:name="Colour" form:id="c1" form:dropdown="true"><form:option form:label="Red"/><form:option form:label="Green" form:current-selected="true"/></form:listbox><form:checkbox form:name="Agree" form:id="c2" form:current-state="unchecked"/></form:form></office:forms><text:p>Colour: <draw:control draw:control="c1"/> Agree: <draw:control draw:control="c2"/></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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn kinds(doc: &wordcraft_doc::Document) -> Vec<&'static str> {
|
||||
@@ -36,8 +32,14 @@ fn odt_form_controls_become_content_controls_everywhere() {
|
||||
assert_eq!(text, "Colour: Green Agree: \u{2610}");
|
||||
// DOCX: Word content controls.
|
||||
let docx = io::save_bytes("form.docx", &doc).unwrap();
|
||||
let mut body = String::new();
|
||||
zip::ZipArchive::new(std::io::Cursor::new(&docx)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut body).unwrap();
|
||||
let body = String::from_utf8(
|
||||
craft_zip::Archive::open(&docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 })
|
||||
.unwrap()
|
||||
.read("word/document.xml", 16 << 20)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(body.contains("<w:dropDownList") && body.contains("w14:checkbox"), "{body}");
|
||||
let again = io::open_bytes("form.docx", &docx).unwrap();
|
||||
assert_eq!(kinds(&again), ["dropDown", "checkBox"]);
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! the object behind: saved as ODT it is the spreadsheet again; saved as DOCX it is the picture
|
||||
//! alone, never ODF markup. The ODT is original and synthetic, shaped like LibreOffice's.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_engine::io;
|
||||
|
||||
@@ -14,8 +12,7 @@ fn odt() -> Vec<u8> {
|
||||
r#"<office:document-content {NS}><office:body><office:text><text:p>Costs <draw:frame svg:width="3in" svg:height="1in"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="./ObjectReplacements/Object 1"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let sheet = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"><office:body><office:spreadsheet/></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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
let entries: [(&str, &[u8]); 4] = [
|
||||
("mimetype", b"application/vnd.oasis.opendocument.text"),
|
||||
("content.xml", content.as_bytes()),
|
||||
@@ -23,17 +20,14 @@ fn odt() -> Vec<u8> {
|
||||
("ObjectReplacements/Object 1", b"VCLMTF not a picture WordCraft draws"),
|
||||
];
|
||||
for (name, data) in entries {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data).unwrap();
|
||||
z.add(name, data, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn entry(pkg: &[u8], name: &str) -> Option<String> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).ok()?.read_to_string(&mut s).ok()?;
|
||||
Some(s)
|
||||
let mut z = craft_zip::Archive::open(pkg, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
String::from_utf8(z.read(name, 16 << 20).ok()??).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,9 +22,8 @@ fn an_odt_is_saved_and_opened_with_a_password() {
|
||||
fn an_odt_template_opens_and_saves_as_a_template_with_a_password_too() {
|
||||
let doc = io::open_bytes("plain.txt", b"Letterhead.").unwrap();
|
||||
let ott = io::save_bytes("letter.ott", &doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&ott)).unwrap();
|
||||
let mut mime = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("mimetype").unwrap(), &mut mime).unwrap();
|
||||
let mut z = craft_zip::Archive::open(&ott, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
let mime = String::from_utf8(z.read("mimetype", 16 << 20).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(mime, wordcraft_formats::odt::TEMPLATE_MIME);
|
||||
let opened = io::open_bytes("letter.ott", &ott).unwrap();
|
||||
assert_eq!(opened.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect::<Vec<_>>(), ["Letterhead."]);
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! whose automatic styles name a master page (`style:master-page-name`), one restarting page
|
||||
//! numbering (`style:page-number`), and a two-column `text:section`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_engine::io;
|
||||
|
||||
const NS: &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""#;
|
||||
@@ -33,20 +31,17 @@ fn odt() -> Vec<u8> {
|
||||
<text:p text:style-name="PBack">Closing words</text:p><text:p>Last line.</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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", &content), ("styles.xml", &styles)] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
let mut zip = craft_zip::Archive::open(docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! headings, with or without a title, and it is saved back as an ODF table of contents. The ODT
|
||||
//! is original and synthetic, shaped like LibreOffice's.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_engine::{Session, io};
|
||||
|
||||
@@ -26,13 +24,11 @@ fn odt(titled: bool) -> Vec<u8> {
|
||||
<text:h text:outline-level="3">Too deep</text:h><text:p>Last.</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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
/// The text of the body's paragraphs in the TOC styles (without an untitled table's field
|
||||
@@ -76,13 +72,11 @@ fn an_odt_index_updates_from_its_marks() {
|
||||
<text:p>Clay <text:alphabetical-index-mark text:string-value="kilns"/>is fired; <text:alphabetical-index-mark-start text:id="m"/>glazes<text:alphabetical-index-mark-end text:id="m"/> shine.</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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let mut s = Session::new(io::open_bytes("index.odt", &z.finish().unwrap().into_inner()).unwrap());
|
||||
let mut s = Session::new(io::open_bytes("index.odt", &z.finish().unwrap()).unwrap());
|
||||
s.run("references.updateIndex", &json!({})).unwrap();
|
||||
let text: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "")).collect();
|
||||
assert!(text.iter().any(|t| t.starts_with("glazes")), "{text:?}");
|
||||
@@ -99,13 +93,11 @@ fn an_odt_bibliography_updates_from_its_citations_sources() {
|
||||
<text:bibliography text:name="B"><text:bibliography-source/><text:index-body><text:p>stale</text:p></text:index-body></text:bibliography>
|
||||
</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);
|
||||
let mut z = craft_zip::Writer::new();
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
|
||||
}
|
||||
let mut s = Session::new(io::open_bytes("bib.odt", &z.finish().unwrap().into_inner()).unwrap());
|
||||
let mut s = Session::new(io::open_bytes("bib.odt", &z.finish().unwrap()).unwrap());
|
||||
s.run("references.updateIndex", &json!({})).unwrap();
|
||||
let text: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "")).collect();
|
||||
assert!(text.iter().any(|t| t.contains("Kilns and Fire")), "{text:?}");
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! Switch semantics: Microsoft MS-OI29500, Part 1 Section 17.16.5.51 (REF).
|
||||
//! All OOXML packages and document text are constructed in memory; no external documents.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{InlineObject, StoryRef};
|
||||
|
||||
@@ -11,12 +9,11 @@ const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2
|
||||
|
||||
/// Build an OOXML ZIP entirely in memory.
|
||||
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let mut zw = craft_zip::Writer::new();
|
||||
for (name, body) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
zw.add(name, body.as_bytes(), craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
zw.finish().unwrap()
|
||||
}
|
||||
|
||||
/// One synthetic numbering level.
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! page defaults, then `\sectd` + section words + the section's header/footer groups, a `\sect`
|
||||
//! after the last `\par` (or after a table), and a `\sectd` inside a footer's field result.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use wordcraft_engine::io;
|
||||
|
||||
const RTF: &str = r"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Times New Roman;}}
|
||||
@@ -28,10 +26,9 @@ const RTF: &str = r"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Times New Roman;}
|
||||
\pard\plain Last line.\par}";
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
let mut zip = craft_zip::Archive::open(docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
|
||||
|
||||
String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -194,10 +194,11 @@ fn an_undecodable_page_background_picture_is_refused_for_pdf_and_png() {
|
||||
|
||||
#[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 png = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 200, 255, 255])).clone()),
|
||||
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() });
|
||||
@@ -208,10 +209,11 @@ fn a_decodable_page_background_picture_passes() {
|
||||
#[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 png = wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(40_000, 10, image::Rgba([9, 9, 9, 255])).clone()),
|
||||
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() });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! 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};
|
||||
@@ -9,11 +8,11 @@ const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/
|
||||
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
|
||||
wordcraft_geom::picture::encode_format(
|
||||
&image::DynamicImage::from(image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255])).clone()),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn pic(rid: &str) -> String {
|
||||
@@ -39,7 +38,7 @@ fn docx(header_media: &[u8], body_media: &[u8]) -> Vec<u8> {
|
||||
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 mut zip = craft_zip::Writer::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()),
|
||||
@@ -51,10 +50,9 @@ fn docx(header_media: &[u8], body_media: &[u8]) -> Vec<u8> {
|
||||
("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.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
zip.finish().unwrap()
|
||||
}
|
||||
|
||||
fn report(d: &Document, pages: Option<std::ops::RangeInclusive<usize>>) -> Vec<(String, String, MissingPictureReason)> {
|
||||
|
||||
+15
-20
@@ -11,33 +11,28 @@ workspace = true
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-docx = { workspace = true }
|
||||
craft-wordmodel = { workspace = true }
|
||||
craft-rtf = { workspace = true }
|
||||
craft-flow = { workspace = true }
|
||||
craft-odf = { workspace = true }
|
||||
craft-markdown = { workspace = true }
|
||||
craft-html = { workspace = true }
|
||||
craft-latex = { workspace = true }
|
||||
craft-math = { workspace = true }
|
||||
craft-mime = { workspace = true }
|
||||
craft-sheet = { workspace = true }
|
||||
craft-ods = { workspace = true }
|
||||
craft-xls = { workspace = true }
|
||||
craft-xlsx = { workspace = true }
|
||||
craft-zip = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-metafile = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
cfb = { workspace = true }
|
||||
craft-metafile = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
chardetng = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
# Password-protected ODT (ODF 1.3 Part 2 §3.4): AES-256-CBC, SHA-1/SHA-256, PBKDF2 (HMAC-SHA-1).
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
getrandom = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
|
||||
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
|
||||
# The browser clock (`SystemTime::now()` panics on wasm32-unknown-unknown): ODT change dates.
|
||||
js-sys = "0.3"
|
||||
# The browser's `crypto.getRandomValues` for the salts and vectors of encrypted saves.
|
||||
getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
craft-image-codecs = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
+35
-42
@@ -1,48 +1,41 @@
|
||||
//! HTML.
|
||||
//!
|
||||
//! Import is a tolerant tag-soup reader (no DOM): a tokenizer plus a stack of open elements with
|
||||
//! HTML's implicit closes for `p`, `li`, `tr`, `td`/`th` and headings. It understands `p`, `div`,
|
||||
//! `h1`–`h6`, `b`/`strong`, `i`/`em`, `u`, `s`/`strike`/`del`, `sup`/`sub`, `code`/`kbd`, `a`
|
||||
//! (links and `id`/`name` anchors), `br`, `hr`, `ul`/`ol`/`li` (nested), `table`/`tr`/`td`/`th`
|
||||
//! (colspan/rowspan), `img` (`data:` URIs, other sources through a caller's [`ImageLoader`]), `blockquote`, `pre`, `font`, `ruby`
|
||||
//! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS
|
||||
//! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`,
|
||||
//! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page
|
||||
//! breaks, and on paragraphs `margin`, `text-indent` and `line-height`. CSS comes from `style`
|
||||
//! attributes and from `style` elements' rules with simple selectors (`p`, `.note`, `p.note`),
|
||||
//! applied in cascade order (specificity, then source order, then the `style` attribute).
|
||||
//! Scripts are skipped; nesting is capped.
|
||||
//!
|
||||
//! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as
|
||||
//! `<ruby>`.
|
||||
//! HTML import and export via `craft-html`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{Document, SectionProps};
|
||||
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Align, Document};
|
||||
pub use craft_html::doc::{ImageLoader, PAGE_FONT, css_len_pt, decode, decode_as, decode_entity, esc, export_flow, parse, parse_with, unescape};
|
||||
|
||||
use crate::model::{
|
||||
self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, Spacing, base64_encode, data_uri, make_img, mime_of, parse_color,
|
||||
};
|
||||
fn shared_section(page: &SectionProps) -> craft_wordmodel::SectionProps {
|
||||
let mut d = Document::new();
|
||||
d.last_section = page.clone();
|
||||
wordcraft_docx::to_shared_model(&d).map(|s| s.last_section).unwrap_or_default()
|
||||
}
|
||||
|
||||
mod builder;
|
||||
mod css;
|
||||
mod entity;
|
||||
mod export;
|
||||
mod import;
|
||||
mod lexer;
|
||||
mod math_ruby;
|
||||
pub fn import(html: &str) -> Document {
|
||||
wordcraft_docx::from_shared_model(craft_html::doc::import(html)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub use builder::ImageLoader;
|
||||
pub use css::css_len_pt;
|
||||
pub use entity::{decode, decode_as, decode_entity, unescape};
|
||||
pub use export::{esc, export, export_flow, export_fragment, inlines_html};
|
||||
#[cfg(test)]
|
||||
use import::parse_in;
|
||||
pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with};
|
||||
pub fn import_page(html: &str, images: ImageLoader<'_>) -> Document {
|
||||
wordcraft_docx::from_shared_model(craft_html::doc::import_page(html, images)).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
use css::css_unescape;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub fn import_with(html: &str, images: ImageLoader<'_>) -> Document {
|
||||
wordcraft_docx::from_shared_model(craft_html::doc::import_with(html, images)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn import_on(html: &str, images: ImageLoader<'_>, page: &SectionProps) -> Document {
|
||||
wordcraft_docx::from_shared_model(craft_html::doc::import_on(html, images, &shared_section(page))).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn export(doc: &Document) -> String {
|
||||
match wordcraft_docx::to_shared_model(doc) {
|
||||
Ok(shared) => craft_html::doc::export(&shared),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_fragment(doc: &Document) -> String {
|
||||
match wordcraft_docx::to_shared_model(doc) {
|
||||
Ok(shared) => craft_html::doc::export_fragment(&shared),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
||||
use super::builder::decls;
|
||||
use super::import::PAGE_FONT;
|
||||
use super::{Align, Fmt, model, parse_color};
|
||||
|
||||
/// A CSS length (or font-size keyword) in points; `None` when it is not one.
|
||||
pub fn css_len_pt(v: &str) -> Option<f32> {
|
||||
let v = v.trim().to_ascii_lowercase();
|
||||
let num = |s: &str| s.trim().parse::<f32>().ok().filter(|x| x.is_finite());
|
||||
if let Some(x) = v.strip_suffix("pt") {
|
||||
num(x)
|
||||
} else if let Some(x) = v.strip_suffix("px") {
|
||||
num(x).map(|p| p * 0.75)
|
||||
} else if let Some(x) = v.strip_suffix("rem").or_else(|| v.strip_suffix("em")) {
|
||||
num(x).map(|p| p * 12.0)
|
||||
} else if let Some(x) = v.strip_suffix("in") {
|
||||
num(x).map(|p| p * 72.0)
|
||||
} else if let Some(x) = v.strip_suffix("cm") {
|
||||
num(x).map(|p| p * 72.0 / 2.54)
|
||||
} else if let Some(x) = v.strip_suffix("mm") {
|
||||
num(x).map(|p| p * 72.0 / 25.4)
|
||||
} else if let Some(x) = v.strip_suffix('%') {
|
||||
num(x).map(|p| p * 0.12)
|
||||
} else {
|
||||
match v.as_str() {
|
||||
"xx-small" => Some(7.0),
|
||||
"x-small" => Some(7.5),
|
||||
"small" | "smaller" => Some(10.0),
|
||||
"medium" => Some(12.0),
|
||||
"large" | "larger" => Some(13.5),
|
||||
"x-large" => Some(18.0),
|
||||
"xx-large" => Some(24.0),
|
||||
"xxx-large" => Some(36.0),
|
||||
_ => num(&v).map(|p| p * 0.75),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn strip_important(v: &str) -> &str {
|
||||
let s = v.trim();
|
||||
if let Some(pos) = s.rfind('!') {
|
||||
let after = s[pos + 1..].trim();
|
||||
if after.eq_ignore_ascii_case("important") {
|
||||
return s[..pos].trim();
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode CSS escapes (CSS Syntax §4.3.7): `\` and one to six hex digits, with one whitespace
|
||||
/// after them, is that code point; `\` and any other character is that character. CR LF, CR and
|
||||
/// form feed count as one newline, as CSS reads them (§3.3).
|
||||
pub(super) fn css_unescape(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
// Takes a newline (CR LF as one) or other whitespace off the front, if there is one.
|
||||
let skip_space = |it: &mut std::iter::Peekable<std::str::Chars>| match it.peek() {
|
||||
Some('\r') => {
|
||||
it.next();
|
||||
it.next_if_eq(&'\n');
|
||||
}
|
||||
Some(' ' | '\t' | '\n' | '\u{C}') => {
|
||||
it.next();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
o.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut hex = String::new();
|
||||
while hex.len() < 6 && it.peek().is_some_and(char::is_ascii_hexdigit) {
|
||||
hex.extend(it.next());
|
||||
}
|
||||
if hex.is_empty() {
|
||||
// Before a newline it continues the line; at the end it is dropped.
|
||||
match it.peek() {
|
||||
Some('\r' | '\n' | '\u{C}') => skip_space(&mut it),
|
||||
Some(_) => o.extend(it.next()),
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
skip_space(&mut it);
|
||||
let cp = u32::from_str_radix(&hex, 16).unwrap_or(0);
|
||||
o.push(char::from_u32(cp).filter(|c| *c != '\0').unwrap_or('\u{FFFD}'));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Apply a `style` attribute to formatting / block properties.
|
||||
pub(super) fn apply_style(
|
||||
css: &str,
|
||||
f: &mut Fmt,
|
||||
align: &mut Option<Align>,
|
||||
preserve: &mut bool,
|
||||
page_break: &mut bool,
|
||||
page_break_after: &mut bool,
|
||||
) {
|
||||
for (k, v) in decls(css) {
|
||||
let lv = v.to_ascii_lowercase();
|
||||
match k.as_str() {
|
||||
"font-weight" => f.bold = lv.starts_with("bold") || lv.parse::<u32>().is_ok_and(|w| w >= 600),
|
||||
"font-style" => f.italic = lv == "italic" || lv == "oblique",
|
||||
"text-decoration" | "text-decoration-line" => {
|
||||
if lv.contains("none") {
|
||||
f.underline = false;
|
||||
f.strike = false;
|
||||
}
|
||||
if lv.contains("underline") {
|
||||
f.underline = true;
|
||||
}
|
||||
if lv.contains("line-through") {
|
||||
f.strike = true;
|
||||
}
|
||||
}
|
||||
"color" => {
|
||||
if let Some(c) = parse_color(v) {
|
||||
f.color = Some(c);
|
||||
}
|
||||
}
|
||||
"background-color" | "background" => {
|
||||
if let Some(c) = v.split_whitespace().find_map(parse_color) {
|
||||
f.background = Some(c);
|
||||
}
|
||||
}
|
||||
"font-size" => {
|
||||
if let Some(p) = css_len_pt(v).filter(|p| *p >= 1.0 && *p <= 1638.0) {
|
||||
f.size = Some(p);
|
||||
}
|
||||
}
|
||||
"font-family" => {
|
||||
let fam = css_unescape(v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']));
|
||||
if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") {
|
||||
f.code = true;
|
||||
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
|
||||
f.font = model::kept_font(&fam).or(f.font.take());
|
||||
} else if f.font.is_some() {
|
||||
// A generic family replaces a face the text has (a page's Times New Roman);
|
||||
// text without one keeps the document's font.
|
||||
match fam.to_ascii_lowercase().as_str() {
|
||||
"serif" => f.font = Some(PAGE_FONT.into()),
|
||||
"sans-serif" => f.font = Some("Arial".into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"vertical-align" => {
|
||||
f.sup = lv == "super";
|
||||
f.sub = lv == "sub";
|
||||
}
|
||||
"text-align" => {
|
||||
*align = match lv.as_str() {
|
||||
"center" => Some(Align::Center),
|
||||
"right" | "end" => Some(Align::Right),
|
||||
"justify" => Some(Align::Justify),
|
||||
"left" | "start" => Some(Align::Left),
|
||||
_ => *align,
|
||||
}
|
||||
}
|
||||
"white-space" => *preserve = lv.starts_with("pre"),
|
||||
"page-break-before" | "break-before" => *page_break = lv == "always" || lv == "page",
|
||||
"page-break-after" | "break-after" => *page_break_after = lv == "always" || lv == "page",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Entities and decoding
|
||||
|
||||
/// Decode an entity body (`amp`, `#38`, `#x26`) to its text.
|
||||
pub fn decode_entity(name: &str) -> Option<String> {
|
||||
decode_char(name).map(String::from)
|
||||
}
|
||||
|
||||
fn decode_char(name: &str) -> Option<char> {
|
||||
if let Some(num) = name.strip_prefix('#') {
|
||||
let (digits, radix) = match num.strip_prefix(['x', 'X']) {
|
||||
Some(h) => (h, 16),
|
||||
None => (num, 10),
|
||||
};
|
||||
if !digits.chars().all(|c| c.is_digit(radix)) {
|
||||
return None;
|
||||
}
|
||||
let v = u32::from_str_radix(digits, radix).ok()?;
|
||||
let c = match v {
|
||||
0 => '\u{FFFD}',
|
||||
0x80..=0x9F => crate::txt::cp1252(v as u8),
|
||||
_ => char::from_u32(v).unwrap_or('\u{FFFD}'),
|
||||
};
|
||||
return Some(c);
|
||||
}
|
||||
let c = match name {
|
||||
"amp" | "AMP" => '&',
|
||||
"lt" | "LT" => '<',
|
||||
"gt" | "GT" => '>',
|
||||
"quot" | "QUOT" => '"',
|
||||
"apos" | "APOS" => '\'',
|
||||
"nbsp" | "NBSP" => '\u{00A0}',
|
||||
"ensp" => '\u{2002}',
|
||||
"emsp" => '\u{2003}',
|
||||
"thinsp" => '\u{2009}',
|
||||
"shy" => '\u{00AD}',
|
||||
"copy" | "COPY" => '©',
|
||||
"reg" | "REG" => '®',
|
||||
"trade" | "TRADE" => '™',
|
||||
"hellip" => '…',
|
||||
"mdash" => '—',
|
||||
"ndash" => '–',
|
||||
"minus" => '−',
|
||||
"lsquo" => '‘',
|
||||
"rsquo" => '’',
|
||||
"sbquo" => '‚',
|
||||
"ldquo" => '“',
|
||||
"rdquo" => '”',
|
||||
"bdquo" => '„',
|
||||
"laquo" => '«',
|
||||
"raquo" => '»',
|
||||
"bull" => '•',
|
||||
"middot" => '·',
|
||||
"deg" => '°',
|
||||
"plusmn" => '±',
|
||||
"times" => '×',
|
||||
"divide" => '÷',
|
||||
"frac12" => '½',
|
||||
"frac14" => '¼',
|
||||
"frac34" => '¾',
|
||||
"frac13" => '⅓',
|
||||
"frac23" => '⅔',
|
||||
"frac18" => '⅛',
|
||||
"frac38" => '⅜',
|
||||
"frac58" => '⅝',
|
||||
"frac78" => '⅞',
|
||||
"sup1" => '¹',
|
||||
"sup2" => '²',
|
||||
"sup3" => '³',
|
||||
"micro" => 'µ',
|
||||
"permil" => '‰',
|
||||
"prime" => '′',
|
||||
"Prime" => '″',
|
||||
"oline" => '‾',
|
||||
"frasl" => '⁄',
|
||||
"sect" => '§',
|
||||
"para" => '¶',
|
||||
"euro" | "EURO" => '€',
|
||||
"pound" | "POUND" => '£',
|
||||
"yen" | "YEN" => '¥',
|
||||
"cent" | "CENT" => '¢',
|
||||
"curren" => '¤',
|
||||
"dagger" => '†',
|
||||
"Dagger" => '‡',
|
||||
"larr" => '←',
|
||||
"rarr" => '→',
|
||||
"uarr" => '↑',
|
||||
"darr" => '↓',
|
||||
"harr" => '↔',
|
||||
"lArr" => '⇐',
|
||||
"rArr" => '⇒',
|
||||
"uArr" => '⇑',
|
||||
"dArr" => '⇓',
|
||||
"hArr" => '⇔',
|
||||
"crarr" => '↵',
|
||||
"forall" => '∀',
|
||||
"part" => '∂',
|
||||
"exist" => '∃',
|
||||
"empty" => '∅',
|
||||
"nabla" => '∇',
|
||||
"isin" => '∈',
|
||||
"notin" => '∉',
|
||||
"ni" => '∋',
|
||||
"prod" => '∏',
|
||||
"sum" => '∑',
|
||||
"radic" => '√',
|
||||
"prop" => '∝',
|
||||
"ang" => '∠',
|
||||
"and" => '∧',
|
||||
"or" => '∨',
|
||||
"cap" => '∩',
|
||||
"cup" => '∪',
|
||||
"int" => '∫',
|
||||
"there4" => '∴',
|
||||
"sim" => '∼',
|
||||
"cong" => '≅',
|
||||
"asymp" | "approx" => '≈',
|
||||
"ne" => '≠',
|
||||
"equiv" => '≡',
|
||||
"le" => '≤',
|
||||
"ge" => '≥',
|
||||
"sub" => '⊂',
|
||||
"sup" => '⊃',
|
||||
"nsub" => '⊄',
|
||||
"sube" => '⊆',
|
||||
"supe" => '⊇',
|
||||
"oplus" => '⊕',
|
||||
"otimes" => '⊗',
|
||||
"perp" => '⊥',
|
||||
"sdot" => '⋅',
|
||||
"infin" => '∞',
|
||||
"loz" => '◊',
|
||||
"spades" => '♠',
|
||||
"clubs" => '♣',
|
||||
"hearts" => '♥',
|
||||
"diams" => '♦',
|
||||
"check" => '✓',
|
||||
"iexcl" => '¡',
|
||||
"iquest" => '¿',
|
||||
"szlig" => 'ß',
|
||||
"auml" => 'ä',
|
||||
"ouml" => 'ö',
|
||||
"uuml" => 'ü',
|
||||
"Auml" => 'Ä',
|
||||
"Ouml" => 'Ö',
|
||||
"Uuml" => 'Ü',
|
||||
"eacute" => 'é',
|
||||
"egrave" => 'è',
|
||||
"ecirc" => 'ê',
|
||||
"euml" => 'ë',
|
||||
"Eacute" => 'É',
|
||||
"Egrave" => 'È',
|
||||
"Ecirc" => 'Ê',
|
||||
"Euml" => 'Ë',
|
||||
"aacute" => 'á',
|
||||
"agrave" => 'à',
|
||||
"acirc" => 'â',
|
||||
"atilde" => 'ã',
|
||||
"Agrave" => 'À',
|
||||
"Aacute" => 'Á',
|
||||
"Acirc" => 'Â',
|
||||
"Atilde" => 'Ã',
|
||||
"iacute" => 'í',
|
||||
"igrave" => 'ì',
|
||||
"icirc" => 'î',
|
||||
"iuml" => 'ï',
|
||||
"Iacute" => 'Í',
|
||||
"Igrave" => 'Ì',
|
||||
"Icirc" => 'Î',
|
||||
"Iuml" => 'Ï',
|
||||
"oacute" => 'ó',
|
||||
"ograve" => 'ò',
|
||||
"ocirc" => 'ô',
|
||||
"otilde" => 'õ',
|
||||
"Oacute" => 'Ó',
|
||||
"Ograve" => 'Ò',
|
||||
"Ocirc" => 'Ô',
|
||||
"Otilde" => 'Õ',
|
||||
"uacute" => 'ú',
|
||||
"ugrave" => 'ù',
|
||||
"ucirc" => 'û',
|
||||
"Uacute" => 'Ú',
|
||||
"Ugrave" => 'Ù',
|
||||
"Ucirc" => 'Û',
|
||||
"yacute" => 'ý',
|
||||
"yuml" => 'ÿ',
|
||||
"Yacute" => 'Ý',
|
||||
"ntilde" => 'ñ',
|
||||
"Ntilde" => 'Ñ',
|
||||
"ccedil" => 'ç',
|
||||
"Ccedil" => 'Ç',
|
||||
"aring" => 'å',
|
||||
"Aring" => 'Å',
|
||||
"oslash" => 'ø',
|
||||
"Oslash" => 'Ø',
|
||||
"aelig" => 'æ',
|
||||
"AElig" => 'Æ',
|
||||
"alpha" => 'α',
|
||||
"beta" => 'β',
|
||||
"gamma" => 'γ',
|
||||
"delta" => 'δ',
|
||||
"epsilon" => 'ε',
|
||||
"zeta" => 'ζ',
|
||||
"eta" => 'η',
|
||||
"theta" => 'θ',
|
||||
"iota" => 'ι',
|
||||
"kappa" => 'κ',
|
||||
"lambda" => 'λ',
|
||||
"mu" => 'μ',
|
||||
"nu" => 'ν',
|
||||
"xi" => 'ξ',
|
||||
"pi" => 'π',
|
||||
"rho" => 'ρ',
|
||||
"sigma" => 'σ',
|
||||
"tau" => 'τ',
|
||||
"upsilon" => 'υ',
|
||||
"phi" => 'φ',
|
||||
"chi" => 'χ',
|
||||
"psi" => 'ψ',
|
||||
"omega" => 'ω',
|
||||
"Gamma" => 'Γ',
|
||||
"Delta" => 'Δ',
|
||||
"Theta" => 'Θ',
|
||||
"Lambda" => 'Λ',
|
||||
"Xi" => 'Ξ',
|
||||
"Pi" => 'Π',
|
||||
"Sigma" => 'Σ',
|
||||
"Upsilon" => 'Υ',
|
||||
"Phi" => 'Φ',
|
||||
"Psi" => 'Ψ',
|
||||
"Omega" => 'Ω',
|
||||
"zwj" => '\u{200D}',
|
||||
"zwnj" => '\u{200C}',
|
||||
_ => return None,
|
||||
};
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// Legacy names HTML5 also decodes without a `;` (only those [`decode_char`] knows), sorted by bytes.
|
||||
const BARE_NAMES: &[&str] = &[
|
||||
"AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute",
|
||||
"Icirc", "Igrave", "Iuml", "LT", "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "Uacute", "Ucirc", "Ugrave",
|
||||
"Uuml", "Yacute", "aacute", "acirc", "aelig", "agrave", "amp", "aring", "atilde", "auml", "ccedil", "cent", "copy", "curren", "deg", "divide",
|
||||
"eacute", "ecirc", "egrave", "euml", "frac12", "frac14", "frac34", "gt", "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt",
|
||||
"micro", "middot", "nbsp", "ntilde", "oacute", "ocirc", "ograve", "oslash", "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg",
|
||||
"sect", "shy", "sup1", "sup2", "sup3", "szlig", "times", "uacute", "ucirc", "ugrave", "uuml", "yacute", "yen", "yuml",
|
||||
];
|
||||
|
||||
/// A reference written without its `;` at the start of `after` (the text after `&`): a numeric one,
|
||||
/// or the longest legacy name. In attribute values a name followed by `=` or an alphanumeric stays text.
|
||||
fn bare_reference(after: &str, attr: bool) -> Option<(char, usize)> {
|
||||
if let Some(num) = after.strip_prefix('#') {
|
||||
let hex = num.starts_with(['x', 'X']);
|
||||
let radix = if hex { 16 } else { 10 };
|
||||
let skip = usize::from(hex);
|
||||
let n = num.get(skip..)?.bytes().take_while(|b| char::from(*b).is_digit(radix)).count();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let len = 1 + skip + n;
|
||||
return decode_char(after.get(..len)?).map(|c| (c, len));
|
||||
}
|
||||
// Legacy names are 2 to 6 ASCII alphanumerics: try the longest prefix first.
|
||||
let run = after.bytes().take(6).take_while(u8::is_ascii_alphanumeric).count();
|
||||
let len = (2..=run).rev().find(|&l| after.get(..l).is_some_and(|p| BARE_NAMES.binary_search(&p).is_ok()))?;
|
||||
let next = after.as_bytes().get(len);
|
||||
if attr && next.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'=') {
|
||||
return None;
|
||||
}
|
||||
decode_char(after.get(..len)?).map(|c| (c, len))
|
||||
}
|
||||
|
||||
/// Replace entity references in text `s`.
|
||||
pub fn unescape(s: &str) -> String {
|
||||
unescape_in(s, false)
|
||||
}
|
||||
|
||||
/// Replace entity references in an attribute value `s`.
|
||||
pub fn unescape_attr(s: &str) -> String {
|
||||
unescape_in(s, true)
|
||||
}
|
||||
|
||||
fn unescape_in(s: &str, attr: bool) -> String {
|
||||
if !s.contains('&') {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(k) = rest.find('&') {
|
||||
out.push_str(&rest[..k]);
|
||||
let after = &rest[k + 1..];
|
||||
// An entity name is short; looking further would rescan the whole text for every stray `&`.
|
||||
let mut cut = after.len().min(33);
|
||||
while !after.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
let end = after.get(..cut).and_then(|w| w.find(';'));
|
||||
match end.and_then(|e| decode_char(&after[..e]).map(|c| (c, e + 1))).or_else(|| bare_reference(after, attr)) {
|
||||
Some((c, e)) => {
|
||||
out.push(c);
|
||||
rest = &after[e..];
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn sniff_charset(head: &str) -> Option<&str> {
|
||||
let (i, n) = head.find("charset").map(|i| (i, 7)).or_else(|| head.find("encoding").map(|i| (i, 8)))?;
|
||||
let after = head.get(i + n..)?.trim_start().strip_prefix('=')?;
|
||||
let trimmed = after.trim_start().trim_start_matches(['"', '\'']);
|
||||
let end = trimmed.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ';' | '>' | '/')).unwrap_or(trimmed.len());
|
||||
let label = trimmed[..end].trim();
|
||||
if label.is_empty() { None } else { Some(label) }
|
||||
}
|
||||
|
||||
/// Decode HTML bytes: BOM, then a `charset` in the first 2 KB, else UTF-8 with a legacy fallback.
|
||||
pub fn decode(b: &[u8]) -> String {
|
||||
decode_as(b, None)
|
||||
}
|
||||
|
||||
/// [`decode`] in the `charset` its container states (a MIME part's, a content type's), when it
|
||||
/// states one. Single-byte Windows code pages (`windows-1250`…, `iso-8859-1`, `us-ascii` as 1252)
|
||||
/// are decoded as such, other labels by their WHATWG decoders.
|
||||
pub fn decode_as(b: &[u8], charset: Option<&str>) -> String {
|
||||
if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||
return crate::txt::decode(b);
|
||||
}
|
||||
let head = String::from_utf8_lossy(b.get(..b.len().min(2048)).unwrap_or(b)).to_ascii_lowercase();
|
||||
let named = charset.map(str::to_ascii_lowercase).or_else(|| sniff_charset(&head).map(str::to_string));
|
||||
let code_page = match named.as_deref().map(str::trim) {
|
||||
Some("iso-8859-1" | "latin1" | "us-ascii") => Some(1252),
|
||||
Some(cs) => cs
|
||||
.strip_prefix("windows-")
|
||||
.or_else(|| cs.strip_prefix("cp"))
|
||||
.and_then(|n| n.parse::<u16>().ok())
|
||||
.filter(|cp| wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(cp)),
|
||||
None => None,
|
||||
};
|
||||
// Other labels (ISO 8859, KOI8, Shift_JIS, GBK, Big5, EUC-KR…) by their WHATWG decoders; a
|
||||
// UTF-16 one without a BOM means UTF-8, as in browsers.
|
||||
let other = named
|
||||
.as_deref()
|
||||
.and_then(|l| encoding_rs::Encoding::for_label(l.trim().as_bytes()))
|
||||
.filter(|e| ![encoding_rs::UTF_8, encoding_rs::UTF_16LE, encoding_rs::UTF_16BE].contains(e));
|
||||
match (code_page, other) {
|
||||
(Some(cp), _) => b.iter().map(|&c| wordcraft_doc::encoding::windows_byte(cp, c)).collect(),
|
||||
(None, Some(enc)) => enc.decode_without_bom_handling(b).0.into_owned(),
|
||||
(None, None) => match std::str::from_utf8(b) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => crate::txt::decode(b),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bare_names_are_sorted_and_known() {
|
||||
assert!(BARE_NAMES.windows(2).all(|w| w[0] < w[1]));
|
||||
assert!(BARE_NAMES.iter().all(|n| (2..=6).contains(&n.len()) && decode_char(n).is_some()));
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
use super::{Align, Document, FBlock, FTable, Flow, Fmt, Inline, Kind, Para, VAlign, base64_encode, mime_of, model};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Export
|
||||
|
||||
/// Escape text for HTML content and attribute values.
|
||||
pub fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 8);
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\u{000C}' | '\u{000E}' => {}
|
||||
_ => o.push(c),
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// A font name as a quoted CSS string. Quotes, `\` and control characters would end the string,
|
||||
/// and `;` and `,` the declaration or the family list for a reader that splits on them, so all of
|
||||
/// these are written as hex escapes ([`css_unescape`] reads them back).
|
||||
fn css_font_name(name: &str) -> String {
|
||||
let mut o = String::with_capacity(name.len() + 2);
|
||||
o.push('\'');
|
||||
for c in name.chars() {
|
||||
match c {
|
||||
'\0' => o.push('\u{FFFD}'),
|
||||
'\'' | '"' | '\\' | ';' | ',' => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c if c.is_control() => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o.push('\'');
|
||||
o
|
||||
}
|
||||
|
||||
fn align_css(a: Option<Align>) -> Option<&'static str> {
|
||||
match a? {
|
||||
Align::Center => Some("text-align:center"),
|
||||
Align::Right => Some("text-align:right"),
|
||||
Align::Justify | Align::Distribute => Some("text-align:justify"),
|
||||
Align::Left => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_html(t: &str, at_start: bool) -> String {
|
||||
let mut o = String::new();
|
||||
let mut prev_space = at_start;
|
||||
for c in t.chars() {
|
||||
match c {
|
||||
'\n' => {
|
||||
o.push_str("<br>");
|
||||
prev_space = true;
|
||||
continue;
|
||||
}
|
||||
'\t' => o.push_str("<span style=\"white-space:pre\">\t</span>"),
|
||||
' ' if prev_space => o.push_str(" "),
|
||||
_ => o.push_str(&esc(&c.to_string())),
|
||||
}
|
||||
prev_space = c == ' ';
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The tags that carry `f`'s character formatting: what opens them, and what closes them
|
||||
/// (innermost last, so the closes are written in reverse).
|
||||
fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) {
|
||||
let mut open = String::new();
|
||||
let mut close: Vec<&str> = Vec::new();
|
||||
// A script or other non-web target is written as plain text (`is_safe_href`).
|
||||
if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
|
||||
open.push_str(&format!("<a href=\"{}\">", esc(l)));
|
||||
close.push("</a>");
|
||||
}
|
||||
let mut tag = |o: &str, c: &'static str| {
|
||||
open.push_str(o);
|
||||
close.push(c);
|
||||
};
|
||||
if f.bold {
|
||||
tag("<strong>", "</strong>");
|
||||
}
|
||||
if f.italic {
|
||||
tag("<em>", "</em>");
|
||||
}
|
||||
if f.underline {
|
||||
tag("<u>", "</u>");
|
||||
}
|
||||
if f.strike {
|
||||
tag("<s>", "</s>");
|
||||
}
|
||||
if f.sup {
|
||||
tag("<sup>", "</sup>");
|
||||
} else if f.sub {
|
||||
tag("<sub>", "</sub>");
|
||||
}
|
||||
if f.code {
|
||||
tag("<code>", "</code>");
|
||||
}
|
||||
let mut css = Vec::new();
|
||||
if let Some(c) = f.color {
|
||||
css.push(format!("color:#{}", c.hex()));
|
||||
}
|
||||
if let Some(c) = f.background {
|
||||
css.push(format!("background-color:#{}", c.hex()));
|
||||
}
|
||||
if let Some(s) = f.size {
|
||||
css.push(format!("font-size:{}pt", trim_num(s)));
|
||||
}
|
||||
if let Some(fam) = &f.font {
|
||||
css.push(format!("font-family:{}", esc(&css_font_name(fam))));
|
||||
}
|
||||
if !css.is_empty() {
|
||||
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
|
||||
close.push("</span>");
|
||||
}
|
||||
(open, close)
|
||||
}
|
||||
|
||||
/// Inline spans → HTML.
|
||||
pub fn inlines_html(inlines: &[Inline]) -> String {
|
||||
let mut o = String::new();
|
||||
for i in inlines.iter() {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
let (open, close) = fmt_tags(f);
|
||||
let at_start = o.is_empty();
|
||||
o.push_str(&open);
|
||||
o.push_str(&text_html(t, at_start));
|
||||
for c in close.iter().rev() {
|
||||
o.push_str(c);
|
||||
}
|
||||
}
|
||||
// A phonetic guide: the base text with its reading in `<rt>` (#1681). HTML ruby has
|
||||
// no alignment or size, so the guide's own attributes stay in the document model.
|
||||
Inline::Ruby { base, ruby, fmt } => {
|
||||
let (open, close) = fmt_tags(fmt);
|
||||
let at_start = o.is_empty();
|
||||
o.push_str(&open);
|
||||
o.push_str(&format!("<ruby>{}<rt>{}</rt></ruby>", text_html(base, at_start), text_html(ruby, false)));
|
||||
for c in close.iter().rev() {
|
||||
o.push_str(c);
|
||||
}
|
||||
}
|
||||
Inline::Image(img) => {
|
||||
o.push_str(&format!(
|
||||
"<img src=\"data:{};base64,{}\" alt=\"{}\" width=\"{}\" height=\"{}\">",
|
||||
mime_of(&img.ext),
|
||||
base64_encode(&img.data),
|
||||
esc(&img.alt),
|
||||
(img.w / 0.75).round(),
|
||||
(img.h / 0.75).round()
|
||||
));
|
||||
}
|
||||
Inline::Anchor(a) => o.push_str(&format!("<a id=\"{}\"></a>", esc(a))),
|
||||
Inline::Note { .. }
|
||||
| Inline::Comment { .. }
|
||||
| Inline::CommentEnd(_)
|
||||
| Inline::Shape { .. }
|
||||
| Inline::Group { .. }
|
||||
| Inline::FieldStart(_)
|
||||
| Inline::FieldEnd
|
||||
| Inline::AnchorEnd(_) => {}
|
||||
// ASK and SET show nothing (see `model::shows_nothing`).
|
||||
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
|
||||
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => {
|
||||
let (open, close) = fmt_tags(fmt);
|
||||
let at_start = o.is_empty();
|
||||
o.push_str(&open);
|
||||
o.push_str(&text_html(result, at_start));
|
||||
for c in close.iter().rev() {
|
||||
o.push_str(c);
|
||||
}
|
||||
}
|
||||
// No picture to show: the browser shows the alt text in its place.
|
||||
Inline::Figure(alt) | Inline::Chart(crate::model::Chart { alt, .. }) => o.push_str(&format!("<img alt=\"{}\">", esc(alt))),
|
||||
// MathML, which browsers, Word and LibreOffice show as an equation (#816).
|
||||
Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)),
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn trim_num(v: f32) -> String {
|
||||
let s = format!("{:.2}", v);
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn para_attrs(p: &Para) -> String {
|
||||
let mut css: Vec<&str> = Vec::new();
|
||||
if let Some(a) = align_css(p.align) {
|
||||
css.push(a);
|
||||
}
|
||||
if p.page_break {
|
||||
css.push("break-before:page;page-break-before:always");
|
||||
}
|
||||
if css.is_empty() { String::new() } else { format!(" style=\"{}\"", css.join(";")) }
|
||||
}
|
||||
|
||||
fn para_html(p: &Para) -> String {
|
||||
let body = inlines_html(&p.inlines);
|
||||
let attrs = para_attrs(p);
|
||||
match p.kind {
|
||||
Kind::Heading(n) => format!("<h{n}{attrs}>{body}</h{n}>", n = n.clamp(1, 6)),
|
||||
Kind::Title => format!("<h1{attrs}>{body}</h1>"),
|
||||
Kind::Rule => "<hr>".to_string(),
|
||||
_ if body.is_empty() => format!("<p{attrs}> </p>"),
|
||||
_ => format!("<p{attrs}>{body}</p>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_html(t: &FTable, out: &mut String, depth: usize) {
|
||||
out.push_str("<table style=\"border-collapse:collapse\">\n");
|
||||
for row in &t.rows {
|
||||
out.push_str("<tr>");
|
||||
for c in row {
|
||||
if c.covered {
|
||||
continue;
|
||||
}
|
||||
let tag = if c.header { "th" } else { "td" };
|
||||
let mut attrs = String::new();
|
||||
if c.colspan > 1 {
|
||||
attrs.push_str(&format!(" colspan=\"{}\"", c.colspan));
|
||||
}
|
||||
if c.rowspan > 1 {
|
||||
attrs.push_str(&format!(" rowspan=\"{}\"", c.rowspan));
|
||||
}
|
||||
let v_css = match c.valign {
|
||||
Some(VAlign::Center) => "vertical-align:middle",
|
||||
Some(VAlign::Bottom) => "vertical-align:bottom",
|
||||
_ => "vertical-align:top",
|
||||
};
|
||||
let mut css = vec!["padding:4px 6px".to_string(), v_css.to_string()];
|
||||
if !t.borderless {
|
||||
css.insert(0, "border:1px solid #999".to_string());
|
||||
}
|
||||
if let Some(s) = c.shading {
|
||||
css.push(format!("background-color:#{}", s.hex()));
|
||||
}
|
||||
let single = match c.blocks.as_slice() {
|
||||
[FBlock::Para(p)] if p.kind == Kind::Normal && p.list.is_none() && !p.page_break => Some(p),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(a) = single.and_then(|p| align_css(p.align)) {
|
||||
css.push(a.to_string());
|
||||
}
|
||||
out.push_str(&format!("<{tag}{attrs} style=\"{}\">", css.join(";")));
|
||||
match single {
|
||||
Some(p) => out.push_str(&inlines_html(&p.inlines)),
|
||||
None => {
|
||||
out.push('\n');
|
||||
blocks_html(&c.blocks, out, depth + 1);
|
||||
}
|
||||
}
|
||||
out.push_str(&format!("</{tag}>"));
|
||||
}
|
||||
out.push_str("</tr>\n");
|
||||
}
|
||||
out.push_str("</table>\n");
|
||||
}
|
||||
|
||||
fn blocks_html(blocks: &[FBlock], out: &mut String, depth: usize) {
|
||||
let mut i = 0;
|
||||
while let Some(b) = blocks.get(i) {
|
||||
match b {
|
||||
FBlock::Table(t) => {
|
||||
if depth < model::MAX_DEPTH {
|
||||
table_html(t, out, depth);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
FBlock::Para(p) if p.list.is_some() => {
|
||||
// Nested lists from consecutive list paragraphs.
|
||||
let mut stack: Vec<(bool, bool)> = Vec::new(); // (ordered, li open)
|
||||
while let Some(FBlock::Para(q)) = blocks.get(i) {
|
||||
let Some(li) = q.list else { break };
|
||||
let lv = li.level.min(8) as usize;
|
||||
while stack.len() > lv + 1 {
|
||||
close_list(&mut stack, out);
|
||||
}
|
||||
if stack.len() == lv + 1 && stack.last().is_some_and(|s| s.0 != li.ordered) {
|
||||
close_list(&mut stack, out);
|
||||
}
|
||||
while stack.len() < lv + 1 {
|
||||
if let Some(top) = stack.last_mut()
|
||||
&& !top.1
|
||||
{
|
||||
out.push_str("<li>");
|
||||
top.1 = true;
|
||||
}
|
||||
out.push_str(if li.ordered { "<ol>" } else { "<ul>" });
|
||||
stack.push((li.ordered, false));
|
||||
}
|
||||
if let Some(top) = stack.last_mut() {
|
||||
if top.1 {
|
||||
out.push_str("</li>\n");
|
||||
}
|
||||
top.1 = true;
|
||||
}
|
||||
let body = inlines_html(&q.inlines);
|
||||
let attrs = para_attrs(q);
|
||||
out.push_str(&format!("<li{attrs}>{body}"));
|
||||
i += 1;
|
||||
}
|
||||
while !stack.is_empty() {
|
||||
close_list(&mut stack, out);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
FBlock::Para(p) if p.kind == Kind::Code => {
|
||||
out.push_str("<pre><code>");
|
||||
let mut first = true;
|
||||
while let Some(FBlock::Para(q)) = blocks.get(i) {
|
||||
if q.kind != Kind::Code || q.list.is_some() {
|
||||
break;
|
||||
}
|
||||
if !first {
|
||||
out.push('\n');
|
||||
}
|
||||
first = false;
|
||||
out.push_str(&esc(&q.text()));
|
||||
i += 1;
|
||||
}
|
||||
out.push_str("</code></pre>\n");
|
||||
}
|
||||
FBlock::Para(p) if p.kind == Kind::Quote => {
|
||||
out.push_str("<blockquote>\n");
|
||||
while let Some(FBlock::Para(q)) = blocks.get(i) {
|
||||
if q.kind != Kind::Quote || q.list.is_some() {
|
||||
break;
|
||||
}
|
||||
out.push_str(¶_html(&Para { kind: Kind::Normal, ..q.clone() }));
|
||||
out.push('\n');
|
||||
i += 1;
|
||||
}
|
||||
out.push_str("</blockquote>\n");
|
||||
}
|
||||
FBlock::Para(p) => {
|
||||
out.push_str(¶_html(p));
|
||||
out.push('\n');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_list(stack: &mut Vec<(bool, bool)>, out: &mut String) {
|
||||
if let Some((ordered, li_open)) = stack.pop() {
|
||||
if li_open {
|
||||
out.push_str("</li>");
|
||||
}
|
||||
out.push_str(if ordered { "</ol>" } else { "</ul>" });
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete HTML document.
|
||||
pub fn export(doc: &Document) -> String {
|
||||
let flow = model::from_doc(doc);
|
||||
export_flow(&flow, doc.styles.default_chr.lang.as_deref().unwrap_or("en"))
|
||||
}
|
||||
|
||||
/// `doc`'s content as an HTML fragment (no head or style sheet): what a copy puts on the system
|
||||
/// clipboard beside its text, equations as MathML (#816).
|
||||
pub fn export_fragment(doc: &Document) -> String {
|
||||
let flow = model::from_doc(doc);
|
||||
let mut out = String::from("<meta charset=\"utf-8\">");
|
||||
blocks_html(&flow.blocks, &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn export_flow(flow: &Flow, lang: &str) -> String {
|
||||
let m = &flow.meta;
|
||||
let mut out = String::new();
|
||||
out.push_str("<!DOCTYPE html>\n");
|
||||
out.push_str(&format!("<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n", esc(lang)));
|
||||
out.push_str("<meta name=\"generator\" content=\"WordCraft\">\n");
|
||||
out.push_str(&format!("<title>{}</title>\n", esc(&m.title)));
|
||||
for (k, v) in [("author", &m.author), ("description", &m.description), ("keywords", &m.keywords), ("subject", &m.subject)] {
|
||||
if !v.is_empty() {
|
||||
out.push_str(&format!("<meta name=\"{k}\" content=\"{}\">\n", esc(v)));
|
||||
}
|
||||
}
|
||||
out.push_str(
|
||||
"<style>\nbody{font-family:Aptos,Calibri,'Segoe UI',Arial,sans-serif;font-size:12pt;line-height:1.15;max-width:48em;margin:2em auto;padding:0 1em}\n\
|
||||
table{border-collapse:collapse;margin:0.5em 0}\nblockquote{margin:0.5em 2em;font-style:italic;color:#404040}\n\
|
||||
pre{background:#f5f5f5;padding:0.5em;font-family:'Courier New',monospace;font-size:10pt}\n</style>\n</head>\n<body>\n",
|
||||
);
|
||||
blocks_html(&flow.blocks, &mut out, 0);
|
||||
out.push_str("</body>\n</html>\n");
|
||||
out
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
use super::builder::{Builder, ImageLoader, parse_sheet};
|
||||
use super::lexer::{Lexer, Tok};
|
||||
use super::math_ruby::{read_math, read_ruby};
|
||||
use super::{Document, Flow, Inline, model};
|
||||
|
||||
/// Parse HTML text into the flow model.
|
||||
pub fn parse(s: &str) -> Flow {
|
||||
parse_with(s, &|_| None)
|
||||
}
|
||||
|
||||
/// [`parse`], loading pictures that aren't `data:` URIs through `images`.
|
||||
pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
parse_in(s, images, false)
|
||||
}
|
||||
|
||||
pub(super) fn parse_in(s: &str, images: ImageLoader, page: bool) -> Flow {
|
||||
// HTML normalizes input newlines before tokenization, not after decoding character references.
|
||||
let normalized = if s.contains('\r') {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chunks = s.split('\r');
|
||||
out.push_str(chunks.next().unwrap_or_default());
|
||||
for chunk in chunks {
|
||||
out.push('\n');
|
||||
out.push_str(chunk.strip_prefix('\n').unwrap_or(chunk));
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(s)
|
||||
};
|
||||
// The lexer's offsets (a style element's text is cut out by them) are into the normalized text.
|
||||
let s: &str = &normalized;
|
||||
let mut lx = Lexer { s, pos: 0 };
|
||||
let mut b = Builder::new(images, page);
|
||||
let mut guard = 0usize;
|
||||
while let Some(t) = lx.next() {
|
||||
guard += 1;
|
||||
if guard > 50_000_000 {
|
||||
break;
|
||||
}
|
||||
match t {
|
||||
Tok::Text(t) => b.text(&t),
|
||||
Tok::Start { name, attrs, self_close } => {
|
||||
b.close_head_for_start(&name);
|
||||
if crate::mathml::local(&name) == "math" && !self_close {
|
||||
let el = read_math(&mut lx, name, attrs);
|
||||
if !b.in_head()
|
||||
&& let Some((linear, display)) = crate::mathml::read(&el)
|
||||
{
|
||||
b.start_para().inlines.push(Inline::Equation { linear, display });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if name == "ruby" && !self_close {
|
||||
let r = read_ruby(&mut lx);
|
||||
if !b.in_head() {
|
||||
b.push_ruby(&r.base, &r.ruby);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close {
|
||||
let from = lx.pos;
|
||||
let to = lx.skip_past_end_tag(&name);
|
||||
if name == "style" {
|
||||
parse_sheet(s.get(from..to).unwrap_or(""), &mut b.sheet);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
b.start(&name, &attrs, self_close);
|
||||
}
|
||||
Tok::End(name) => b.end(&name),
|
||||
}
|
||||
}
|
||||
b.finish()
|
||||
}
|
||||
|
||||
/// Collapse white space inside ruby markup as body text does (HTML's white space only: an
|
||||
/// ideographic space is text), without the space the element's indentation leaves at the edges.
|
||||
pub(super) fn collapse_ws(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') {
|
||||
if !out.ends_with(' ') {
|
||||
out.push(' ');
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.trim_matches(' ').to_string()
|
||||
}
|
||||
|
||||
/// Parse HTML into a document.
|
||||
pub fn import(s: &str) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc(&parse(s))
|
||||
}
|
||||
|
||||
/// [`import`], loading pictures that aren't `data:` URIs through `images`.
|
||||
pub fn import_with(s: &str, images: ImageLoader) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc(&parse_with(s, images))
|
||||
}
|
||||
|
||||
/// A web page opened as a document, as Word opens one (a file, or a document's HTML
|
||||
/// `w:altChunk`): its text is Times New Roman 12 pt on single lines, headings bold and black at
|
||||
/// the sizes browsers give them, and a `p` or heading whose CSS leaves a margin out (or writes it
|
||||
/// invalidly) gets HTML's automatic spacing there; a margin a span states replaces the one its
|
||||
/// paragraph states (both as Word's import does). [`import`] reads a pasted fragment, which takes
|
||||
/// the document's own formatting instead (but its CSS still applies).
|
||||
pub fn import_page(s: &str, images: ImageLoader) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc(&parse_in(s, images, true))
|
||||
}
|
||||
|
||||
/// [`import_page`] onto `page` (its size and margins), which tables size to: HTML gives no page
|
||||
/// size, so the caller picks it (the region's paper, #1381).
|
||||
pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc_in(&parse_in(s, images, true), page.clone())
|
||||
}
|
||||
|
||||
/// The font of a web page's text when its CSS names none.
|
||||
pub const PAGE_FONT: &str = "Times New Roman";
|
||||
/// Heading sizes on a web page, `h1`–`h6`, points.
|
||||
pub(super) const PAGE_HEADING_SIZES: [f32; 6] = [24.0, 18.0, 13.5, 12.0, 10.0, 7.5];
|
||||
@@ -1,176 +0,0 @@
|
||||
use super::entity::{unescape, unescape_attr};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Tokenizer
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum Tok {
|
||||
Text(String),
|
||||
Start { name: String, attrs: Vec<(String, String)>, self_close: bool },
|
||||
End(String),
|
||||
}
|
||||
|
||||
pub(super) struct Lexer<'a> {
|
||||
pub(super) s: &'a str,
|
||||
pub(super) pos: usize,
|
||||
}
|
||||
|
||||
impl Lexer<'_> {
|
||||
fn rest(&self) -> &str {
|
||||
self.s.get(self.pos..).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Skip past the next occurrence of `pat` (ASCII case-insensitive), or to the end.
|
||||
fn skip_past(&mut self, pat: &str) -> usize {
|
||||
let start = self.pos;
|
||||
let hay = self.rest().as_bytes();
|
||||
let p = pat.as_bytes();
|
||||
let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p));
|
||||
match found {
|
||||
Some(k) => self.pos += k + p.len(),
|
||||
None => self.pos = self.s.len(),
|
||||
}
|
||||
found.map(|k| start + k).unwrap_or(self.s.len())
|
||||
}
|
||||
|
||||
/// Skip past the next matching end tag `</tag...>` (ASCII case-insensitive), or to the end.
|
||||
/// Skip past the end tag of raw text element `tag`; returns where that end tag starts (the
|
||||
/// end of the input when there is none).
|
||||
pub(super) fn skip_past_end_tag(&mut self, tag: &str) -> usize {
|
||||
let prefix = format!("</{tag}");
|
||||
let p = prefix.as_bytes();
|
||||
while self.pos < self.s.len() {
|
||||
let hay = self.rest().as_bytes();
|
||||
let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p));
|
||||
let Some(k) = found else {
|
||||
self.pos = self.s.len();
|
||||
return self.pos;
|
||||
};
|
||||
let after_tag = k + p.len();
|
||||
let next_byte = hay.get(after_tag);
|
||||
if next_byte.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/') {
|
||||
let at = self.pos + k;
|
||||
self.pos += after_tag;
|
||||
self.skip_past(">");
|
||||
return at;
|
||||
}
|
||||
self.pos += k + 2;
|
||||
}
|
||||
self.s.len()
|
||||
}
|
||||
|
||||
pub(super) fn next(&mut self) -> Option<Tok> {
|
||||
let rest = self.rest();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !rest.starts_with('<') {
|
||||
let end = rest.find('<').unwrap_or(rest.len());
|
||||
let t = unescape(&rest[..end]);
|
||||
self.pos += end;
|
||||
return Some(Tok::Text(t));
|
||||
}
|
||||
let b = rest.as_bytes();
|
||||
if rest.starts_with("<!--") {
|
||||
if rest.starts_with("<!-->") {
|
||||
self.pos += 5;
|
||||
} else if rest.starts_with("<!--->") {
|
||||
self.pos += 6;
|
||||
} else {
|
||||
self.pos += 4;
|
||||
self.skip_past("-->");
|
||||
}
|
||||
return Some(Tok::Text(String::new()));
|
||||
}
|
||||
if rest.starts_with("<!") || rest.starts_with("<?") {
|
||||
self.skip_past(">");
|
||||
return Some(Tok::Text(String::new()));
|
||||
}
|
||||
if rest.starts_with("</") {
|
||||
let name: String = rest
|
||||
.strip_prefix("</")
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-')
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
if name.is_empty() {
|
||||
self.pos += 2;
|
||||
return Some(Tok::Text("</".into()));
|
||||
}
|
||||
self.skip_past(">");
|
||||
return Some(Tok::End(name));
|
||||
}
|
||||
if !b.get(1).is_some_and(u8::is_ascii_alphabetic) {
|
||||
self.pos += 1;
|
||||
return Some(Tok::Text("<".into()));
|
||||
}
|
||||
// Start tag.
|
||||
let name: String =
|
||||
rest[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').map(|c| c.to_ascii_lowercase()).collect();
|
||||
let mut i = 1 + name.len();
|
||||
let mut attrs = Vec::new();
|
||||
let mut self_close = false;
|
||||
loop {
|
||||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
match b.get(i) {
|
||||
None => break,
|
||||
Some(b'>') => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(b'/') => {
|
||||
if b.get(i + 1) == Some(&b'>') {
|
||||
self_close = true;
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let a0 = i;
|
||||
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'=' | b'>' | b'/')) {
|
||||
i += 1;
|
||||
}
|
||||
let an = rest.get(a0..i).unwrap_or("").to_ascii_lowercase();
|
||||
if an.is_empty() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
let mut val = String::new();
|
||||
if b.get(i) == Some(&b'=') {
|
||||
i += 1;
|
||||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||||
i += 1;
|
||||
}
|
||||
match b.get(i) {
|
||||
Some(&q) if q == b'"' || q == b'\'' => {
|
||||
let v0 = i + 1;
|
||||
let e = rest.get(v0..).and_then(|r| r.find(q as char)).map(|k| v0 + k).unwrap_or(rest.len());
|
||||
val = unescape_attr(rest.get(v0..e).unwrap_or(""));
|
||||
i = e + 1;
|
||||
}
|
||||
_ => {
|
||||
let v0 = i;
|
||||
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') {
|
||||
i += 1;
|
||||
}
|
||||
val = unescape_attr(rest.get(v0..i).unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
if attrs.len() < 64 {
|
||||
attrs.push((an, val));
|
||||
}
|
||||
}
|
||||
self.pos += i.min(rest.len());
|
||||
Some(Tok::Start { name, attrs, self_close })
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
use super::builder::block_kind;
|
||||
use super::lexer::{Lexer, Tok};
|
||||
|
||||
/// A `<math>` element (#816): its tokens up to the matching end tag, as a tree. Nesting and size
|
||||
/// are bounded; an unclosed element ends with the input.
|
||||
pub(super) fn read_math(lx: &mut Lexer, name: String, attrs: Vec<(String, String)>) -> crate::mathml::El {
|
||||
use crate::mathml::{El, Item, MAX_NODES, local};
|
||||
let mut stack: Vec<El> = vec![El { name, attrs, children: Vec::new() }];
|
||||
let mut count = 0usize;
|
||||
let close = |stack: &mut Vec<El>| {
|
||||
if stack.len() > 1
|
||||
&& let Some(done) = stack.pop()
|
||||
&& let Some(parent) = stack.last_mut()
|
||||
{
|
||||
parent.children.push(Item::El(done));
|
||||
}
|
||||
};
|
||||
while let Some(t) = lx.next() {
|
||||
count += 1;
|
||||
if count > MAX_NODES * 4 {
|
||||
// Over the cap: drop the rest of this element so its tokens aren't read as HTML text.
|
||||
skip_math(lx, t);
|
||||
break;
|
||||
}
|
||||
match t {
|
||||
Tok::Text(t) => {
|
||||
if let Some(top) = stack.last_mut()
|
||||
&& !t.is_empty()
|
||||
{
|
||||
top.children.push(Item::Text(t));
|
||||
}
|
||||
}
|
||||
Tok::Start { name, attrs, self_close } => {
|
||||
let el = El { name, attrs, children: Vec::new() };
|
||||
if self_close || stack.len() > wordcraft_doc::math::MAX_DEPTH {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
top.children.push(Item::El(el));
|
||||
}
|
||||
} else {
|
||||
stack.push(el);
|
||||
}
|
||||
}
|
||||
Tok::End(name) => {
|
||||
let name = local(&name).to_string();
|
||||
// Close up to the matching open element; a stray end tag is ignored.
|
||||
if let Some(k) = stack.iter().rposition(|e| local(&e.name) == name) {
|
||||
if k == 0 {
|
||||
break;
|
||||
}
|
||||
while stack.len() > k {
|
||||
close(&mut stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while stack.len() > 1 {
|
||||
close(&mut stack);
|
||||
}
|
||||
stack.pop().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Skip tokens (starting with `first`) up to and including the `</math>` that closes the element
|
||||
/// being read; nested `<math>` elements are counted. An unclosed element ends with the input.
|
||||
fn skip_math(lx: &mut Lexer, first: Tok) {
|
||||
use crate::mathml::local;
|
||||
let mut depth = 0usize;
|
||||
let mut t = Some(first);
|
||||
while let Some(tok) = t {
|
||||
match tok {
|
||||
Tok::Start { name, self_close: false, .. } if local(&name) == "math" => depth = depth.saturating_add(1),
|
||||
Tok::End(name) if local(&name) == "math" => {
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
t = lx.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// A `<ruby>` element's base text and reading (#1681).
|
||||
#[derive(Default)]
|
||||
pub(super) struct RubyText {
|
||||
pub(super) base: String,
|
||||
pub(super) ruby: String,
|
||||
}
|
||||
|
||||
/// Which part of a `<ruby>` element the text being read belongs to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum RubyPart {
|
||||
/// The text being annotated: an `<rb>` child, or the text outside `<rt>`/`<rp>`.
|
||||
Base,
|
||||
/// The reading: an `<rt>` child.
|
||||
Ruby,
|
||||
/// `<rp>` fallback parentheses, which are not text: browsers show them only where ruby isn't
|
||||
/// supported.
|
||||
Fallback,
|
||||
}
|
||||
|
||||
/// A block-level tag ends an open `<ruby>` implicitly, as it does in a browser.
|
||||
fn ends_ruby(t: &Tok) -> bool {
|
||||
match t {
|
||||
Tok::Start { name, .. } | Tok::End(name) => block_kind(name).is_some(),
|
||||
Tok::Text(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a `<ruby>` element's content: its base text from `<rb>` children (or the text outside
|
||||
/// them), its reading from `<rt>` children, and `<rp>` fallback parentheses dropped. Tolerant,
|
||||
/// like the rest of the reader: tags inside are ignored, an unclosed element ends at the next
|
||||
/// block-level tag (handed back to the caller) or with the input, and the tokens read are capped.
|
||||
pub(super) fn read_ruby(lx: &mut Lexer) -> RubyText {
|
||||
/// Most tokens one `<ruby>` element is read from: a missing end tag must not scan the rest of
|
||||
/// a hostile file here.
|
||||
const MAX_TOKENS: usize = 4096;
|
||||
let mut out = RubyText::default();
|
||||
let mut part = RubyPart::Base;
|
||||
let mut nested = 0usize;
|
||||
let mut count = 0usize;
|
||||
while count < MAX_TOKENS {
|
||||
count += 1;
|
||||
let start = lx.pos;
|
||||
let Some(t) = lx.next() else { break };
|
||||
if nested == 0 && ends_ruby(&t) {
|
||||
// The boundary ends the element: give the token back to the main reader.
|
||||
lx.pos = start;
|
||||
break;
|
||||
}
|
||||
match t {
|
||||
Tok::Text(t) => match part {
|
||||
RubyPart::Base => out.base.push_str(&t),
|
||||
RubyPart::Ruby => out.ruby.push_str(&t),
|
||||
RubyPart::Fallback => {}
|
||||
},
|
||||
// The self-closing slash is ignored on HTML elements (`<rt/>` opens an `rt`), as in a
|
||||
// browser's parser.
|
||||
Tok::Start { name, self_close, .. } => match name.as_str() {
|
||||
"rb" => part = RubyPart::Base,
|
||||
"rt" => part = RubyPart::Ruby,
|
||||
"rp" => part = RubyPart::Fallback,
|
||||
"ruby" if !self_close => nested += 1,
|
||||
_ => {}
|
||||
},
|
||||
Tok::End(name) => match name.as_str() {
|
||||
"rb" | "rt" | "rp" => part = RubyPart::Base,
|
||||
"ruby" if nested > 0 => nested -= 1,
|
||||
"ruby" => break,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,769 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numeric_entity_rejects_sign() {
|
||||
assert_eq!(unescape("a&#+65;b"), "a&#+65;b");
|
||||
assert_eq!(unescape("a&#x+41;b"), "a&#x+41;b");
|
||||
assert_eq!(unescape("aAAb"), "aAAb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn important_declarations_win_the_cascade() {
|
||||
let color = |html: &str| fmt_of(paras(&parse(html))[0]).color;
|
||||
let red = Some(wordcraft_doc::Rgb(255, 0, 0));
|
||||
let blue = Some(wordcraft_doc::Rgb(0, 0, 255));
|
||||
assert_eq!(color("<style>p { color: red }</style><p style='color: blue'>x</p>"), blue);
|
||||
assert_eq!(color("<style>p { color: red !important }</style><p style='color: blue'>x</p>"), red);
|
||||
assert_eq!(color("<style>p { color: red !important }</style><p style='color: blue !important'>x</p>"), blue);
|
||||
assert_eq!(color("<p style='color: red !important; color: blue'>x</p>"), red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_charset_decodes_the_page() {
|
||||
// ISO 8859-2 (Central European): 0xA9 Š, 0xB9 š; named by the caller or the page.
|
||||
assert_eq!(decode_as(&[0xA9, b' ', 0xB9], Some("iso-8859-2")), "Š š");
|
||||
assert!(decode_as(b"<meta charset=iso-8859-2><p>\xA9</p>", None).contains("<p>Š</p>"));
|
||||
assert_eq!(decode_as(&[0x8A], Some("windows-1252")), "Š");
|
||||
// A page that says UTF-16 without a BOM is read as UTF-8, as browsers do.
|
||||
assert_eq!(decode_as("é".as_bytes(), Some("utf-16")), "é");
|
||||
}
|
||||
|
||||
fn texts(f: &Flow) -> Vec<String> {
|
||||
f.blocks
|
||||
.iter()
|
||||
.map(|b| match b {
|
||||
FBlock::Para(p) => format!("{:?}{:?}:{}", p.kind, p.list.map(|l| (l.ordered, l.level)), p.text()),
|
||||
FBlock::Table(t) => format!("table{}x{}", t.rows.len(), t.cols()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn table_style(html: &str) -> Option<String> {
|
||||
let doc = crate::model::to_doc(&parse(html));
|
||||
doc.body.iter().find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Table(t) => Some(t.props.style.clone().unwrap_or_default()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borderless_tables_get_no_grid_style() {
|
||||
assert_eq!(table_style("<table><tr><td>a<td>b</table>").as_deref(), Some(""));
|
||||
assert_eq!(table_style("<table style='border:none'><tr><td style='border:0'>a</table>").as_deref(), Some(""));
|
||||
assert_eq!(table_style("<table border=0><tr><td style='border: 0px none'>a</table>").as_deref(), Some(""));
|
||||
assert_eq!(table_style("<table border=1><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||||
assert_eq!(table_style("<table style='border:1px solid #000'><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||||
assert_eq!(table_style("<table><tr><td style='border:1px solid #999'>a</table>").as_deref(), Some("TableGrid"));
|
||||
// The last declaration decides: a `style` attribute's over the style sheet's.
|
||||
assert_eq!(table_style("<style>table { border: 1px solid }</style><table><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||||
assert_eq!(table_style("<style>table { border: 1px solid }</style><table style='border:none'><tr><td>a</table>").as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borderless_tables_export_without_cell_borders() {
|
||||
// #438: a table without grid style and borders writes no cell borders and reopens borderless.
|
||||
for (src, grid) in [
|
||||
("<table style='border:none'><tr><td>A<td>B</table>", false),
|
||||
("<table><tr><td>A<td>B</table>", false),
|
||||
("<table border=1><tr><td>A<td>B</table>", true),
|
||||
] {
|
||||
let out = export(&crate::model::to_doc(&parse(src)));
|
||||
assert_eq!(out.contains("border:1px"), grid, "{src}: {out}");
|
||||
assert_eq!(table_style(&out).as_deref(), Some(if grid { "TableGrid" } else { "" }), "{src}");
|
||||
assert!(out.contains(">A</td>") && out.contains(">B</td>"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_newlines_preserve_preformatted_lines() {
|
||||
for newline in ["\n", "\r\n", "\r"] {
|
||||
let f = parse(&format!("<pre>{newline} Alpha{newline} <b>Beta</b>{newline}{newline} Gamma</pre>"));
|
||||
assert_eq!(texts(&f), ["CodeNone: Alpha", "CodeNone: Beta", "CodeNone:", "CodeNone: Gamma"], "{newline:?}");
|
||||
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
|
||||
assert!(matches!(p.inlines.last(), Some(Inline::Text(t, f)) if t == "Beta" && f.bold));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_newlines_are_normalized_before_character_references() {
|
||||
let f = parse("<p style='white-space:pre'>A B\r\nC\rD</p><p>A\r\nB\rC</p>");
|
||||
assert_eq!(texts(&f), ["NormalNone:A\rB\nC\nD", "NormalNone:A B C"]);
|
||||
let f = parse("<p style='white-space:pre-wrap'>α\r\r\nβ γ</p><a href='https://example.test/a\r\nb\rc d'>link</a>");
|
||||
assert_eq!(texts(&f), ["NormalNone:α\n\nβ\rγ", "NormalNone:link"]);
|
||||
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
|
||||
assert!(matches!(p.inlines.first(), Some(Inline::Text(_, f)) if f.link.as_deref() == Some("https://example.test/a\nb\nc\rd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_preformatted_paragraphs_keep_edge_spaces() {
|
||||
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
|
||||
// paragraphs still collapse.
|
||||
let f = parse(
|
||||
"<p style=\"white-space:pre\"> Alpha Beta </p><p style=\"white-space:pre-wrap\">\n Gamma \n</p><pre> Alpha </pre><p> Alpha Beta </p>",
|
||||
);
|
||||
assert_eq!(texts(&f), vec!["NormalNone: Alpha Beta ", "NormalNone: Gamma ", "CodeNone: Alpha ", "NormalNone:Alpha Beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_soup() {
|
||||
let f = parse(
|
||||
"<html><head><title>T & U</title><style>p{}</style><script>if(a<b)x</script></head><body><h1>Head</h1><p>One <b>bold<p>Two <i>it</i>\n text<ul><li>a<li>b<ol><li>c</ol></ul><table><tr><td>x<td colspan=2>y<tr><th>z</table><blockquote>q</blockquote><pre>l1\nl2</pre>",
|
||||
);
|
||||
assert_eq!(f.meta.title, "T & U");
|
||||
assert_eq!(
|
||||
texts(&f),
|
||||
vec![
|
||||
"Heading(1)None:Head",
|
||||
"NormalNone:One bold",
|
||||
"NormalNone:Two it text",
|
||||
"NormalSome((false, 0)):a",
|
||||
"NormalSome((false, 0)):b",
|
||||
"NormalSome((true, 1)):c",
|
||||
"table2x3",
|
||||
"QuoteNone:q",
|
||||
"CodeNone:l1",
|
||||
"CodeNone:l2",
|
||||
]
|
||||
);
|
||||
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
|
||||
assert!(matches!(&p.inlines[1], Inline::Text(t, f) if t == "bold" && f.bold));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_head_keeps_body_text() {
|
||||
let f = parse("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>T</title>\n<body><p>Hello world</p><p>Second</p></body></html>");
|
||||
assert_eq!(f.meta.title, "T");
|
||||
assert_eq!(texts(&f), vec!["NormalNone:Hello world", "NormalNone:Second"]);
|
||||
|
||||
for body in [
|
||||
"<p>Hello world</p>",
|
||||
"<div>Hello world</div>",
|
||||
"<span>Hello world</span>",
|
||||
"<a href='https://example.test'>Hello world</a>",
|
||||
"<custom>Hello world</custom>",
|
||||
"<textarea>ignored</textarea>Hello world",
|
||||
] {
|
||||
let f = parse(&format!(
|
||||
"<html><head><title>T & U</title><meta name='author' content='Ann'><style>p{{}}</style><script>ignored</script>{body}",
|
||||
));
|
||||
assert_eq!(f.meta.title, "T & U");
|
||||
assert_eq!(f.meta.author, "Ann");
|
||||
assert_eq!(texts(&f), vec!["NormalNone:Hello world"], "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_and_links() {
|
||||
let f = parse("<p style='text-align:center'><span style=\"color:#ff0000;font-weight:700\">red</span> <a href=\"http://x\">l</a><br>n</p>");
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
assert_eq!(p.align, Some(Align::Center));
|
||||
assert!(matches!(&p.inlines[0], Inline::Text(t, f) if t == "red" && f.bold && f.color == Some(wordcraft_doc::Rgb(255,0,0))));
|
||||
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("http://x"))));
|
||||
assert!(p.text().ends_with("l\nn"));
|
||||
}
|
||||
|
||||
/// The paragraphs of every cell of the first table, row by row.
|
||||
fn cell_paras(f: &Flow) -> Vec<Vec<Vec<Para>>> {
|
||||
let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") };
|
||||
t.rows
|
||||
.iter()
|
||||
.map(|r| r.iter().map(|c| c.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.clone()) } else { None }).collect()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn all_bold(p: &Para) -> bool {
|
||||
!p.inlines.is_empty() && p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.bold))
|
||||
}
|
||||
|
||||
fn any_bold(p: &Para) -> bool {
|
||||
p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) if f.bold))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_cells_are_bold_unless_their_css_says_otherwise() {
|
||||
// #1381: a <th> is bold, as in browsers and Word; `font-weight:normal` on it or inside it wins.
|
||||
let f = parse(
|
||||
"<table><thead><tr><th>Head</th><th style='font-weight:normal'>Plain</th><th><span style='font-weight:400'>Inner</span></th></tr></thead><tbody><tr><td>a<td>b<td>c</tbody></table>",
|
||||
);
|
||||
let rows = cell_paras(&f);
|
||||
assert!(all_bold(&rows[0][0][0]), "{:?}", rows[0][0]);
|
||||
assert!(!any_bold(&rows[0][1][0]), "{:?}", rows[0][1]);
|
||||
assert!(!any_bold(&rows[0][2][0]), "{:?}", rows[0][2]);
|
||||
assert!(!any_bold(&rows[1][0][0]), "td stays regular");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_text_align_reaches_the_cell_paragraphs() {
|
||||
// #1381: `text-align` on a td/th, or inherited from its tr/thead/tbody/table, aligns the
|
||||
// cell's paragraphs; a paragraph's own text-align inside the cell wins.
|
||||
let f = parse(
|
||||
"<table style='text-align:center'><tbody style='text-align:justify'><tr style='text-align:right'>\
|
||||
<td style='text-align:right;'>r</td><td>inherited</td><td style='text-align:right'><p style='text-align:center'>own</p></td>\
|
||||
<td align=right>attr</td></tr><tr><td>body</td></tr></tbody><tr><td>table</td></tr></table>",
|
||||
);
|
||||
let rows = cell_paras(&f);
|
||||
assert_eq!(rows[0][0][0].align, Some(Align::Right), "td style");
|
||||
assert_eq!(rows[0][1][0].align, Some(Align::Right), "from tr");
|
||||
assert_eq!(rows[0][2][0].align, Some(Align::Center), "own paragraph wins");
|
||||
assert_eq!(rows[0][3][0].align, Some(Align::Right), "align attribute");
|
||||
assert_eq!(rows[1][0][0].align, Some(Align::Justify), "from tbody");
|
||||
assert_eq!(rows[2][0][0].align, Some(Align::Center), "from table");
|
||||
// `align` on a table places the table; it doesn't align the text in its cells.
|
||||
let f = parse("<table align=center><tr><td>x</td></tr></table>");
|
||||
assert_eq!(cell_paras(&f)[0][0][0].align, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reporter_sample_header_bold_and_right_aligned_cells() {
|
||||
// #1381: the reporter's table.
|
||||
let f = parse(
|
||||
"<table border=\"1\" style=\"width:100%;border-collapse:collapse;\">\n<thead><tr style=\"background-color:#e8f0fa;\"><th>Indicatore</th><th>Valore atteso</th></tr></thead>\n<tbody>\n<tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr>\n</tbody></table>",
|
||||
);
|
||||
let rows = cell_paras(&f);
|
||||
assert!(all_bold(&rows[0][0][0]) && all_bold(&rows[0][1][0]), "{:?}", rows[0]);
|
||||
assert_eq!(rows[1][1][0].align, Some(Align::Right));
|
||||
assert_eq!(rows[1][0][0].align, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities() {
|
||||
assert_eq!(unescape("a <b> AB &bogus; &"), "a <b> AB &bogus; &");
|
||||
assert_eq!(unescape("− © &EURO; ™ ½ ¾"), "− © € ™ ½ ¾");
|
||||
assert_eq!(unescape("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
|
||||
}
|
||||
|
||||
/// Browsers decode numeric and legacy named references without their `;` in text,
|
||||
/// but keep `&name=` and `&nameX` intact inside attribute values (query strings).
|
||||
#[test]
|
||||
fn references_without_semicolon() {
|
||||
assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme");
|
||||
assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme");
|
||||
assert_eq!(unescape("© 2024 & <"), "© 2024 & <");
|
||||
assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &");
|
||||
assert_eq!(unescape(" x"), "\u{00A0}x");
|
||||
let f = parse("<p><a href=\"/q?a=1©=2®2=3&b=<\">l</a> © 2024</p>");
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("/q?a=1©=2®2=3&b=<"))));
|
||||
assert!(p.text().ends_with("© 2024"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wbr_word_break_opportunity() {
|
||||
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
|
||||
let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("expected para") };
|
||||
assert_eq!(p.text(), "Super\u{200B}cali\u{200B}fragilistic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_break_after_and_before() {
|
||||
let f = parse("<p style='page-break-after: always'>First</p><p>Second</p>");
|
||||
let paras: Vec<_> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||||
assert_eq!(paras.len(), 2);
|
||||
assert!(!paras[0].page_break);
|
||||
assert!(paras[1].page_break);
|
||||
|
||||
let f2 = parse("<div style='break-after: page'><p>Part 1</p></div><p>Part 2</p>");
|
||||
let paras2: Vec<_> = f2.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||||
assert_eq!(paras2.len(), 2);
|
||||
assert!(!paras2[0].page_break);
|
||||
assert!(paras2[1].page_break);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_relative_and_absolute() {
|
||||
let f = parse("<font size=\"+1\">larger</font><font size=\"-1\">smaller</font><font size=\"5\">huge</font>");
|
||||
let inls = match &f.blocks[0] {
|
||||
FBlock::Para(p) => &p.inlines,
|
||||
_ => panic!("expected para"),
|
||||
};
|
||||
let sizes: Vec<Option<f32>> = inls
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
Inline::Text(_, fmt) => fmt.size,
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sizes, vec![Some(13.5), Some(10.0), Some(18.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_with_charset_declarations() {
|
||||
let iso = b"<html><head><meta charset=\"iso-8859-1\"></head><body>\xE9l\xE8ve</body></html>";
|
||||
assert!(decode(iso).contains("élève"));
|
||||
|
||||
let win = b"<html><head><meta charset='windows-1252'></head><body>\x80100</body></html>";
|
||||
assert!(decode(win).contains("€100"));
|
||||
|
||||
let gbk = b"<html><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=gbk\"></head><body>\xC4\xE3\xBA\xC3</body></html>";
|
||||
assert!(decode(gbk).contains("你好"));
|
||||
|
||||
let xml = b"<?xml version=\"1.0\" encoding=\"utf-8\"?><html><body>hello</body></html>";
|
||||
assert!(decode(xml).contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_text_elements_skip_only_matching_end_tag() {
|
||||
let f = parse("<script>var a = \"</scripted>\"; var b = \"<b>malicious</b>\";</script><p>Hello</p>");
|
||||
assert_eq!(f.blocks.len(), 1);
|
||||
let p = match &f.blocks[0] {
|
||||
FBlock::Para(p) => p,
|
||||
b => panic!("expected para, got {b:?}"),
|
||||
};
|
||||
assert_eq!(p.text(), "Hello");
|
||||
|
||||
let f = parse("<style>/* </styles> */ p { color: red; }</style><p>World</p>");
|
||||
assert_eq!(f.blocks.len(), 1);
|
||||
let p = match &f.blocks[0] {
|
||||
FBlock::Para(p) => p,
|
||||
b => panic!("expected para, got {b:?}"),
|
||||
};
|
||||
assert_eq!(p.text(), "World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_space_survives_round_trip() {
|
||||
let doc = Document::from_text(" lead");
|
||||
let html = export(&doc);
|
||||
assert!(!html.contains("<p> lead"), "{html}");
|
||||
let back = import(&html);
|
||||
assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_escapes_read_every_kind_of_newline() {
|
||||
// A backslash before CR LF, CR, LF or form feed continues the line; after hex digits, one
|
||||
// of them (CR LF as one) ends the escape.
|
||||
assert_eq!(css_unescape("A\\\r\nB\\\rC\\\nD\\\u{C}E"), "ABCDE");
|
||||
assert_eq!(css_unescape("\\41\r\nB \\42\u{C}C \\43 D"), "AB BC CD");
|
||||
assert_eq!(css_unescape("x\\'y\\"), "x'y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_are_written_as_css_strings() {
|
||||
// A font name could end the CSS string or the declaration and add CSS of its own (#3739).
|
||||
let fonts = ["Arial\n;position:fixed", "Back\\slash", "O'Brien \"Sans\"", "A,B", "Times New Roman", "M+ 1p"];
|
||||
let mut p = Para::default();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
p.push_text(&format!("t{i}"), &Fmt { font: Some((*font).into()), ..Default::default() });
|
||||
}
|
||||
let html = export(&model::to_doc(&Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() }));
|
||||
assert!(html.contains("font-family:'Times New Roman'") && html.contains("font-family:'M+ 1p'"), "{html}");
|
||||
let styles: Vec<&str> = html.split(" style=\"").skip(1).filter_map(|s| s.split('"').next()).filter(|s| s.contains("font-family")).collect();
|
||||
assert_eq!(styles.len(), fonts.len(), "{html}");
|
||||
for s in styles {
|
||||
assert!(!s.contains([';', '\n']) && s.matches('\'').count() == 2, "{s}");
|
||||
}
|
||||
// Read back, each run has its font as it was.
|
||||
let back = parse(&html);
|
||||
let runs: Vec<(String, Option<String>)> = back
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
||||
.flat_map(|p| p.inlines.iter())
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.font.as_deref().map(String::from))) } else { None })
|
||||
.collect();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
assert!(runs.contains(&(format!("t{i}"), Some(font.to_string()))), "{font:?} in {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document that reads "I read " then a phonetic guide over `base`, then " daily.".
|
||||
fn ruby_doc(base: &str, ruby: &str) -> Document {
|
||||
let props = wordcraft_doc::CharProps::default();
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone());
|
||||
let n = p.len();
|
||||
p.insert_object(n, crate::model::ruby_object(base.into(), ruby.into(), 20.0), &props).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_text(n, " daily.", &props).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
d
|
||||
}
|
||||
|
||||
/// The first flow ruby: its base text and reading.
|
||||
fn ruby_of(f: &Flow) -> Option<(String, String)> {
|
||||
f.blocks.iter().find_map(|b| match b {
|
||||
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||||
Inline::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first document ruby: its base text and reading.
|
||||
fn doc_ruby(d: &Document) -> Option<(String, String)> {
|
||||
d.body.iter().find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first document ruby: base size, ruby size, raise and alignment.
|
||||
fn doc_ruby_attrs(d: &Document) -> Option<(f32, f32, f32, wordcraft_doc::para::RubyAlign)> {
|
||||
d.body.iter().find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Ruby { base_size, size, raise, align, .. } => Some((*base_size, *size, *raise, *align)),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_exports_as_ruby_markup() {
|
||||
// #1681: the base text with its reading in `<rt>`, not plain base text.
|
||||
let out = export(&ruby_doc("漢字", "かんじ"));
|
||||
assert!(out.contains("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>"), "{out}");
|
||||
// The base and the reading are escaped like any other text.
|
||||
let out = export(&ruby_doc("A&B", "x<y"));
|
||||
assert!(out.contains("<ruby>A&B<rt>x<y</rt></ruby>"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_imports_with_its_surrounding_text() {
|
||||
let f = parse("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>");
|
||||
assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into())));
|
||||
assert_eq!(texts(&f), vec!["NormalNone:I read 漢字 daily."]);
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
assert!(matches!(&p.inlines[0], Inline::Text(t, _) if t == "I read "), "{:?}", p.inlines);
|
||||
assert!(matches!(p.inlines.last(), Some(Inline::Text(t, _)) if t == " daily."), "{:?}", p.inlines);
|
||||
// One document object, holding the base text and the reading.
|
||||
assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_rb_and_rp_fallbacks_import_as_one_guide() {
|
||||
// `<rb>` holds the base text and `<rp>` the fallback parentheses for browsers without
|
||||
// ruby: the parentheses are not text, and the base pieces join.
|
||||
let f = parse("<p><ruby><rb>漢</rb><rb>字</rb><rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby></p>");
|
||||
assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into())));
|
||||
assert_eq!(texts(&f), vec!["NormalNone:漢字"]);
|
||||
assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_import_uses_the_phonetic_guide_defaults() {
|
||||
// HTML states no alignment, size or raise: the Phonetic Guide's own defaults over a
|
||||
// 20 pt run (half the base size, raised clear of the base text, centred)…
|
||||
let d = crate::html::import("<p style='font-size:20pt'><ruby>漢字<rt>かんじ</rt></ruby></p>");
|
||||
let Some((base_size, size, raise, align)) = doc_ruby_attrs(&d) else { panic!("no ruby") };
|
||||
assert_eq!((base_size, size, align.ooxml()), (20.0, 10.0, "center"));
|
||||
assert!((raise - 18.0).abs() < 0.001, "{raise}");
|
||||
// …and the guide's 10.5 pt fallback base size when the source states none.
|
||||
let d = crate::html::import("<p><ruby>漢字<rt>かんじ</rt></ruby></p>");
|
||||
let Some((base_size, size, raise, _)) = doc_ruby_attrs(&d) else { panic!("no ruby") };
|
||||
assert_eq!((base_size, size), (10.5, 5.0));
|
||||
assert!((raise - 9.4).abs() < 0.001, "{raise}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_survives_an_html_round_trip() {
|
||||
let html = export(&ruby_doc("漢字", "かんじ"));
|
||||
assert_eq!(ruby_of(&parse(&html)), Some(("漢字".into(), "かんじ".into())));
|
||||
let back = crate::html::import(&html);
|
||||
assert_eq!(doc_ruby(&back), Some(("漢字".into(), "かんじ".into())));
|
||||
assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_keeps_the_run_formatting() {
|
||||
let props = wordcraft_doc::CharProps { bold: Some(true), ..Default::default() };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone());
|
||||
let n = p.len();
|
||||
p.insert_object(n, crate::model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap();
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
assert!(export(&d).contains("<strong><ruby>漢字<rt>かんじ</rt></ruby></strong>"), "{}", export(&d));
|
||||
// Importing it back keeps the formatting for the base text and the reading.
|
||||
let f = parse("<p><strong><ruby>漢字<rt>かんじ</rt></ruby></strong></p>");
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
assert!(matches!(&p.inlines[0], Inline::Ruby { fmt, .. } if fmt.bold), "{:?}", p.inlines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ruby_keeps_its_text() {
|
||||
// No reading, an empty reading, a missing base, an unclosed element and a block boundary
|
||||
// closing it: none of them panics, and the text inside is kept.
|
||||
let cases: [(&str, &[&str]); 7] = [
|
||||
("<p>a <ruby>漢字</ruby> b</p>", &["NormalNone:a 漢字 b"]),
|
||||
("<p>a <ruby>漢字<rt></rt></ruby> b</p>", &["NormalNone:a 漢字 b"]),
|
||||
("<p>a <ruby><rt>かんじ</rt></ruby> b</p>", &["NormalNone:a かんじ b"]),
|
||||
("<p>a <ruby><rt></rt></ruby> b</p>", &["NormalNone:a b"]),
|
||||
("<ruby>", &[]),
|
||||
("<p>a <ruby>漢字<rt>かんじ</rt> b</p><p>next</p>", &["NormalNone:a 漢字 b", "NormalNone:next"]),
|
||||
("<p>a <ruby>漢字<rt>かんじ</rt></p><p>next</p>", &["NormalNone:a 漢字", "NormalNone:next"]),
|
||||
];
|
||||
for (src, want) in cases {
|
||||
assert_eq!(texts(&parse(src)), want, "{src}");
|
||||
}
|
||||
// Ruby in ruby: the inner element's text joins the outer guide's.
|
||||
assert_eq!(ruby_of(&parse("<p><ruby>漢字<ruby>字<rt>じ</rt></ruby><rt>かんじ</rt></ruby></p>")), Some(("漢字字".into(), "じかんじ".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_big_and_css_font_sizes() {
|
||||
let f = parse(
|
||||
"<p><small>small</small> <big>big</big> <span style=\"font-size: xxx-large\">huge</span> <span style=\"font-size: smaller\">tiny</span></p>",
|
||||
);
|
||||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||||
let sz = |i: usize| match &p.inlines[i] {
|
||||
Inline::Text(_, f) => f.size,
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(sz(0), Some(10.0));
|
||||
assert_eq!(sz(2), Some(14.0));
|
||||
assert_eq!(sz(4), Some(36.0));
|
||||
assert_eq!(sz(6), Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_important_casing_and_spacing() {
|
||||
let f = parse(
|
||||
"<p style='color: #ff0000 !IMPORTANT; font-size: 16pt ! important; font-weight: 700 !Important; text-align: center !IMPORTANT'>Styled</p>",
|
||||
);
|
||||
assert_eq!(f.blocks.len(), 1);
|
||||
let p = match &f.blocks[0] {
|
||||
FBlock::Para(p) => p,
|
||||
b => panic!("expected para, got {b:?}"),
|
||||
};
|
||||
assert_eq!(p.align, Some(Align::Center));
|
||||
match &p.inlines[0] {
|
||||
Inline::Text(_, fmt) => {
|
||||
assert_eq!(fmt.color, Some(wordcraft_doc::props::Rgb(0xFF, 0x00, 0x00)));
|
||||
assert_eq!(fmt.size, Some(16.0));
|
||||
assert!(fmt.bold);
|
||||
}
|
||||
i => panic!("expected text inline, got {i:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_vertical_alignment() {
|
||||
let f = parse("<table><tr><td valign=top>top</td><td valign=\"center\">mid</td><td style=\"vertical-align: bottom\">bot</td></tr></table>");
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert_eq!(t.rows[0][0].valign, Some(VAlign::Top));
|
||||
assert_eq!(t.rows[0][1].valign, Some(VAlign::Center));
|
||||
assert_eq!(t.rows[0][2].valign, Some(VAlign::Bottom));
|
||||
let doc = crate::model::to_doc(&f);
|
||||
let wordcraft_doc::Block::Table(dt) = &*doc.body[0] else { panic!() };
|
||||
assert_eq!(dt.rows[0].cells[0].props.valign, Some(VAlign::Top));
|
||||
assert_eq!(dt.rows[0].cells[1].props.valign, Some(VAlign::Center));
|
||||
assert_eq!(dt.rows[0].cells[2].props.valign, Some(VAlign::Bottom));
|
||||
let html = export(&doc);
|
||||
assert!(html.contains("vertical-align:top"));
|
||||
assert!(html.contains("vertical-align:middle"));
|
||||
assert!(html.contains("vertical-align:bottom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_formatted_leading_breaks_trim_quickly() {
|
||||
// #3705: a paragraph opening with alternating bold and italic `<br>` was trimmed one
|
||||
// span at a time with `Vec::remove(0)`.
|
||||
let html = format!("<p>{}x</p>", "<b><br></b><i><br></i>".repeat(100_000));
|
||||
let t = std::time::Instant::now();
|
||||
let f = parse(&html);
|
||||
// About a second on a loaded machine; trimming one span at a time ran past 6 minutes.
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
let texts: Vec<String> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_at_the_integer_limits_does_not_overflow() {
|
||||
// `<font size>` offsets were added to 3 unchecked, so the extreme values panicked in debug builds.
|
||||
for size in ["+2147483647", "--2147483648", "-2147483647", "+99999999999"] {
|
||||
let f = parse(&format!("<font size=\"{size}\">x</font>"));
|
||||
assert!(texts(&f).iter().any(|t| t.ends_with(":x")), "size={size}");
|
||||
}
|
||||
// The offsets saturate, then clamp to the 1..=7 scale: 7 is 36 pt, 1 is 7.5 pt.
|
||||
let size = |html: &str| match &parse(html).blocks[0] {
|
||||
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||||
Inline::Text(_, fmt) => fmt.size,
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(size("<font size=\"+2147483647\">x</font>"), Some(36.0));
|
||||
assert_eq!(size("<font size=\"--2147483648\">x</font>"), Some(36.0));
|
||||
assert_eq!(size("<font size=\"-2147483647\">x</font>"), Some(7.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_comments_do_not_swallow_the_rest() {
|
||||
for html in ["<p>a<!-->b</p><p>c</p>", "<p>a<!--->b</p><p>c</p>"] {
|
||||
let doc = crate::html::import(html);
|
||||
assert_eq!(doc.plain_text(Default::default()), "ab\nc", "{html}");
|
||||
}
|
||||
}
|
||||
|
||||
fn paras(f: &Flow) -> Vec<&Para> {
|
||||
f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect()
|
||||
}
|
||||
fn fmt_of(p: &Para) -> Fmt {
|
||||
p.inlines.iter().find_map(|i| if let Inline::Text(_, f) = i { Some(f.clone()) } else { None }).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_sheet_rules_apply_in_cascade_order() {
|
||||
let f = parse(
|
||||
"<style><!-- p { color: #00ff00; font-size: 10pt } .big { font-size: 20pt } p.big { font-weight: bold } \
|
||||
/* a comment { } */ @media print { p { color: #ff0000 } } div p, li:hover { font-style: italic } .000003 { text-align: center } --></style>\
|
||||
<p>plain</p><p class='big x'>big</p><p class=big style='font-size: 8pt'>inline wins</p><p class=000003>digits</p>",
|
||||
);
|
||||
let ps = paras(&f);
|
||||
let (plain, big, inline, digits) = (fmt_of(ps[0]), fmt_of(ps[1]), fmt_of(ps[2]), fmt_of(ps[3]));
|
||||
assert_eq!((plain.color, plain.size, plain.bold, plain.italic), (Some(wordcraft_doc::Rgb(0, 255, 0)), Some(10.0), false, false));
|
||||
assert_eq!((big.size, big.bold), (Some(20.0), true));
|
||||
assert_eq!(inline.size, Some(8.0));
|
||||
assert_eq!(ps[3].align, Some(Align::Center));
|
||||
assert_eq!(digits.size, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_css_states_spacing_indents_and_line_height() {
|
||||
let f = parse(
|
||||
"<style>p.a { margin: 6pt 1in 3pt; text-indent: -18pt; line-height: 150% }</style>\
|
||||
<p class=a>a</p><p style='margin-top: 14,4px; margin-bottom: auto; line-height: 20pt'>b</p><p>c</p>",
|
||||
);
|
||||
let ps = paras(&f);
|
||||
assert_eq!(
|
||||
ps[0].spacing,
|
||||
Spacing {
|
||||
before: Some(6.0),
|
||||
after: Some(3.0),
|
||||
left: Some(72.0),
|
||||
right: Some(72.0),
|
||||
first: Some(-18.0),
|
||||
line: Some(LineSpacing::Multiple(1.5)),
|
||||
..Spacing::default()
|
||||
}
|
||||
);
|
||||
// Values that don't parse (a comma decimal, `auto`) state nothing.
|
||||
assert_eq!(ps[1].spacing, Spacing { line: Some(LineSpacing::Exactly(20.0)), ..Spacing::default() });
|
||||
assert_eq!(ps[2].spacing, Spacing::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_opens_as_word_opens_one() {
|
||||
let html = "<style>p.k { margin-bottom: 13px } span.z { margin: 0in }</style>\
|
||||
<p>first</p><h1>Head</h1><p class=k><span class=z>kept</span></p><p class=k>own</p><div>bare</div><p> </p>";
|
||||
let f = parse_in(html, &|_| None, true);
|
||||
let ps = paras(&f);
|
||||
// Times New Roman 12 pt on single lines; headings bold and black at a browser's sizes.
|
||||
let first = fmt_of(ps[0]);
|
||||
assert_eq!((first.font.as_deref(), first.size), (Some(PAGE_FONT), Some(12.0)));
|
||||
assert_eq!(ps[0].spacing.line, Some(LineSpacing::Multiple(1.0)));
|
||||
let cell = parse_in("<table><tr><td>cell</td></tr></table>", &|_| None, true);
|
||||
let FBlock::Table(t) = &cell.blocks[0] else { panic!() };
|
||||
let FBlock::Para(cp) = &t.rows[0][0].blocks[0] else { panic!() };
|
||||
assert_eq!(cp.spacing.line, Some(LineSpacing::Multiple(1.0)));
|
||||
let head = fmt_of(ps[1]);
|
||||
assert_eq!((head.bold, head.size, head.color), (true, Some(24.0), Some(wordcraft_doc::Rgb(0, 0, 0))));
|
||||
// A colour the heading inherits stays.
|
||||
// A generic family names a face on a page; pasted text keeps the document's font.
|
||||
let generic = parse_in("<p style='font-family: sans-serif'>a</p><p style='font-family: serif'>b</p>", &|_| None, true);
|
||||
let fonts: Vec<Option<String>> = paras(&generic).iter().map(|p| fmt_of(p).font.map(|f| f.to_string())).collect();
|
||||
assert_eq!(fonts, [Some("Arial".to_string()), Some(PAGE_FONT.to_string())]);
|
||||
assert_eq!(fmt_of(paras(&parse("<p style='font-family: sans-serif'>a</p>"))[0]).font, None);
|
||||
let inherited = parse_in("<body style='color: #000080'><h1>Head</h1></body>", &|_| None, true);
|
||||
assert_eq!(fmt_of(paras(&inherited)[0]).color, Some(wordcraft_doc::Rgb(0, 0, 0x80)));
|
||||
// Automatic spacing where a p or heading leaves its margins out (layout drops it at the
|
||||
// top of the page and of a cell).
|
||||
let auto = |p: &Para| (p.spacing.before_auto, p.spacing.after_auto, p.spacing.before, p.spacing.after);
|
||||
assert_eq!(auto(ps[0]), (true, true, None, None));
|
||||
assert_eq!(auto(ps[1]), (true, true, None, None));
|
||||
// A span's margin replaces the one its paragraph states; the one it leaves out stays automatic.
|
||||
assert_eq!(auto(ps[2]), (true, false, None, Some(0.0)));
|
||||
assert_eq!(auto(ps[3]), (true, false, None, Some(9.75)));
|
||||
// Other blocks get no automatic spacing.
|
||||
assert_eq!(auto(ps[4]), (false, false, None, None));
|
||||
// The document says so, and the empty paragraph's mark keeps its size.
|
||||
let d = model::to_doc(&f);
|
||||
let props = |i: usize| match &*d.body[i] {
|
||||
wordcraft_doc::Block::Para(p) => p.clone(),
|
||||
b => panic!("{b:?}"),
|
||||
};
|
||||
assert_eq!((props(0).props.space_before_auto, props(0).props.space_after_auto), (Some(true), Some(true)));
|
||||
assert_eq!((props(2).props.space_after_auto, props(2).props.space_after), (None, Some(0.0)));
|
||||
assert_eq!(props(5).mark.size, Some(12.0));
|
||||
// `<p> </p>` keeps the size of its space for its line.
|
||||
assert!(ps[5].inlines.is_empty());
|
||||
assert_eq!(ps[5].mark.as_ref().and_then(|m| m.size), Some(12.0));
|
||||
// A fragment (a paste) keeps the document's own formatting.
|
||||
let pasted = parse(html);
|
||||
assert_eq!(fmt_of(paras(&pasted)[0]).font, None);
|
||||
assert_eq!(paras(&pasted)[0].spacing, Spacing::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_give_their_columns_widths() {
|
||||
let f = parse(
|
||||
"<style>td.n { width: 42px }</style><table border=1><tr><td colspan=3>t</td></tr><tr><td class=n>1</td><td width=200>a</td><td style='width: 2in'>b</td></tr></table>",
|
||||
);
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!("{:?}", f.blocks) };
|
||||
assert_eq!(t.widths, [31.5, 150.0, 144.0]);
|
||||
// A column without a width leaves them all to the layout.
|
||||
let f = parse("<table><tr><td width=100>a</td><td>b</td></tr></table>");
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert!(t.widths.is_empty());
|
||||
// A cell beside a cell spanning rows from above gives its own column its width.
|
||||
let f =
|
||||
parse("<table><tr><td rowspan=2>a</td><td width=200>b</td></tr><tr><td width=300>c</td></tr><tr><td width=100>d</td><td>e</td></tr></table>");
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert_eq!(t.widths, [75.0, 150.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_take_their_css_borders() {
|
||||
use wordcraft_doc::props::{Border, BorderStyle};
|
||||
let f = parse(
|
||||
"<style>td.b { border-top: solid windowtext 1px; border-bottom: 2pt double #ff0000; border-left: none; border-right: 0px solid; vertical-align: bottom }</style><table><tr><td class=b>a</td><td>b</td></tr></table>",
|
||||
);
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
// The style sheet's other cell properties apply too.
|
||||
assert_eq!(t.rows[0][0].valign, Some(wordcraft_doc::props::VAlign::Bottom));
|
||||
let b = t.rows[0][0].borders.expect("borders");
|
||||
assert_eq!(b.top, Some(Border::single(0.75)));
|
||||
assert_eq!(b.bottom, Some(Border { style: BorderStyle::Double, width: 2.0, color: Some(wordcraft_doc::Rgb(255, 0, 0)), space: 0.0 }));
|
||||
assert_eq!(b.left.map(|l| l.style), Some(BorderStyle::None));
|
||||
// A border of no width is none (OOXML has no zero-width line).
|
||||
assert_eq!(b.right.map(|r| r.style), Some(BorderStyle::None));
|
||||
assert_eq!(t.rows[0][1].borders, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_sheet_edge_cases() {
|
||||
let f = parse_in(
|
||||
"<style>@charset \"utf-8\"; @import url(a.css); p.a { color: #0000ff } body { margin: 20px } p.b { margin: 10 5% medium }</style>\
|
||||
<body><p class=a>a</p>bare<p class=b>b</p></body>",
|
||||
&|_| None,
|
||||
true,
|
||||
);
|
||||
let ps = paras(&f);
|
||||
// A rule right after statement at-rules still applies.
|
||||
assert_eq!(fmt_of(ps[0]).color, Some(wordcraft_doc::Rgb(0, 0, 255)));
|
||||
// The body's margins aren't the paragraphs' indents.
|
||||
assert_eq!((ps[1].spacing.left, ps[1].spacing.before), (None, None));
|
||||
// Bare numbers, percentages and keywords state no margin: automatic spacing stays.
|
||||
assert_eq!((ps[2].spacing.before_auto, ps[2].spacing.left), (true, None));
|
||||
// The `style` attribute wins even over a sheet with more declarations than are read.
|
||||
let many = (0..600).map(|i| format!("x-{i}: 1")).collect::<Vec<_>>().join(";");
|
||||
let f = parse(&format!("<style>p {{ {many}; color: #ff0000 }}</style><p style='color: #00ff00'>c</p>"));
|
||||
assert_eq!(fmt_of(paras(&f)[0]).color, Some(wordcraft_doc::Rgb(0, 255, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_style_sheets_never_panic() {
|
||||
// One declaration block shared by many selectors isn't copied for each.
|
||||
let selectors = vec!["p"; 20_000].join(",");
|
||||
let huge = format!("<style>{selectors} {{ color: red; {} }}</style><p>x</p>", "x: y;".repeat(200_000));
|
||||
let _ = parse_in(&huge, &|_| None, true);
|
||||
let many = ".a { color: red } ".repeat(20_000);
|
||||
for css in ["{", "}", "p {", "@media { p { x } ", "p { color: red", "/* open", ".{}", "p..a{}", "\u{0}.é{x:y}", many.as_str()] {
|
||||
let _ = parse_in(&format!("<style>{css}</style><p class='a é'>x</p><td style='width: 1e40px'>y</td>"), &|_| None, true);
|
||||
}
|
||||
}
|
||||
+22
-42
@@ -1,46 +1,26 @@
|
||||
//! LaTeX (`.tex`) import and export.
|
||||
//!
|
||||
//! **Export** writes a standalone LaTeX 2ε `article` that compiles with pdfLaTeX, XeLaTeX or
|
||||
//! LuaLaTeX using only packages every TeX distribution ships (`fontenc`, `ulem`, `xcolor`,
|
||||
//! `multirow`, `amsmath`, `hyperref`). Headings become unnumbered sectioning commands (Word headings
|
||||
//! carry no numbers), lists `itemize`/`enumerate`, tables `tabular` with the document's column
|
||||
//! widths, equations `$…$` / `\[…\]`, and character formatting the matching text commands. Pictures
|
||||
//! can't live inside a single `.tex` file, so each is replaced by its alternative text.
|
||||
//!
|
||||
//! **Import** reads the everyday subset of LaTeX that documents are written in: the preamble's
|
||||
//! `\title`/`\author`, sectioning commands, paragraphs, lists, `quote`, `center`/`flush…`,
|
||||
//! `verbatim`-like environments, `tabular` (with `\multicolumn`/`\multirow`), inline and display math
|
||||
//! (kept as equations), text formatting commands and declarations, colours, links, accents and
|
||||
//! TeX's special characters and ligatures. It is not a TeX engine: macros aren't expanded. Unknown
|
||||
//! commands are dropped while their braced arguments are kept as text, so no words are lost; layout
|
||||
//! commands (`\vspace`, `\setlength`, `\usepackage`…) are dropped with their arguments.
|
||||
//!
|
||||
//! Like the other text formats, the parser is lenient and never panics on hostile input: group and
|
||||
//! environment nesting is depth-limited and the input size is capped.
|
||||
//! LaTeX (`.tex`) import and export via `craft-latex`.
|
||||
|
||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Para};
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
use wordcraft_doc::{Document, SectionProps};
|
||||
|
||||
mod export;
|
||||
mod lex;
|
||||
mod limits;
|
||||
mod math;
|
||||
mod parser;
|
||||
mod table;
|
||||
mod units;
|
||||
pub use craft_latex::{escape_text, latex_to_linear, linear_to_latex};
|
||||
|
||||
#[cfg(test)]
|
||||
use export::Writer;
|
||||
pub use export::{escape_text, export};
|
||||
#[cfg(test)]
|
||||
use lex::{Tok, tokenize};
|
||||
#[cfg(test)]
|
||||
use limits::MAX_NEST;
|
||||
pub use limits::MAX_SOURCE;
|
||||
pub use math::{latex_to_linear, linear_to_latex};
|
||||
pub use parser::{import, parse};
|
||||
#[cfg(test)]
|
||||
use units::{length_pt, spec_widths};
|
||||
pub fn import(bytes: &[u8]) -> Document {
|
||||
wordcraft_docx::from_shared_model(craft_latex::import(bytes)).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub fn import_on(bytes: &[u8], page: &SectionProps) -> Document {
|
||||
let mut d = import(bytes);
|
||||
d.last_section = page.clone();
|
||||
d
|
||||
}
|
||||
|
||||
pub fn parse(text: &str) -> craft_flow::Flow {
|
||||
craft_latex::parse(text)
|
||||
}
|
||||
|
||||
pub fn export(doc: &Document) -> String {
|
||||
match wordcraft_docx::to_shared_model(doc) {
|
||||
Ok(shared) => craft_latex::export(&shared),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
use super::math::linear_to_latex;
|
||||
use super::{Align, Cell, Document, FBlock, FTable, Fmt, Inline, Kind, Para, Rgb, model};
|
||||
|
||||
/// Text width of the exported article in points (`\linewidth` when reading widths).
|
||||
pub(super) const LINE_WIDTH: f32 = 345.0;
|
||||
|
||||
/// LaTeX's `\tabcolsep` (both sides of a cell), points.
|
||||
pub(super) const TABCOLSEP: f32 = 6.0;
|
||||
|
||||
// =============================================================================================
|
||||
// Export
|
||||
|
||||
/// Export `doc` as a LaTeX source file.
|
||||
pub fn export(doc: &Document) -> String {
|
||||
let flow = model::from_doc(doc);
|
||||
let mut w = Writer::default();
|
||||
let title_at = flow.blocks.iter().position(|b| matches!(b, FBlock::Para(p) if p.kind == Kind::Title && !p.is_empty()));
|
||||
let mut title = String::new();
|
||||
if let Some(FBlock::Para(p)) = title_at.and_then(|i| flow.blocks.get(i)) {
|
||||
title = w.inlines(&p.inlines);
|
||||
}
|
||||
w.title_at = title_at;
|
||||
w.blocks(&flow.blocks);
|
||||
let body = std::mem::take(&mut w.out);
|
||||
|
||||
let meta = &flow.meta;
|
||||
let mut o = String::new();
|
||||
o.push_str("% Written by WordCraft (https://getartcraft.com/apps/wordcraft).\n");
|
||||
o.push_str("\\documentclass{article}\n");
|
||||
o.push_str("\\usepackage[T1]{fontenc}\n");
|
||||
if w.uses.ulem {
|
||||
o.push_str("\\usepackage[normalem]{ulem}\n");
|
||||
}
|
||||
if w.uses.color {
|
||||
o.push_str("\\usepackage[table]{xcolor}\n");
|
||||
}
|
||||
if w.uses.multirow {
|
||||
o.push_str("\\usepackage{multirow}\n");
|
||||
}
|
||||
if w.uses.math {
|
||||
o.push_str("\\usepackage{amsmath}\n");
|
||||
o.push_str("\\usepackage{amssymb}\n");
|
||||
}
|
||||
o.push_str("\\usepackage{hyperref}\n");
|
||||
let pdf = [("pdftitle", &meta.title), ("pdfauthor", &meta.author), ("pdfsubject", &meta.subject), ("pdfkeywords", &meta.keywords)];
|
||||
let set: Vec<String> = pdf.iter().filter(|(_, v)| !v.trim().is_empty()).map(|(k, v)| format!("{k}={{{}}}", escape_text(v.trim()))).collect();
|
||||
if !set.is_empty() {
|
||||
o.push_str(&format!("\\hypersetup{{{}}}\n", set.join(", ")));
|
||||
}
|
||||
if title_at.is_some() {
|
||||
o.push_str(&format!("\\title{{{title}}}\n"));
|
||||
// `\maketitle` shows only the title: the document didn't show an author or a date.
|
||||
o.push_str("\\author{}\n\\date{}\n");
|
||||
}
|
||||
o.push_str("\n\\begin{document}\n\n");
|
||||
o.push_str(body.trim_end());
|
||||
o.push_str("\n\n\\end{document}\n");
|
||||
o
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Uses {
|
||||
ulem: bool,
|
||||
color: bool,
|
||||
multirow: bool,
|
||||
math: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Writer {
|
||||
pub(super) out: String,
|
||||
uses: Uses,
|
||||
/// Index of the paragraph `\maketitle` stands for (top level only).
|
||||
title_at: Option<usize>,
|
||||
/// Inside a table cell: no `verbatim`, paragraphs separated by `\par`.
|
||||
in_cell: bool,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
const SECTIONS: [&str; 6] = ["section", "subsection", "subsubsection", "paragraph", "subparagraph", "subparagraph"];
|
||||
|
||||
impl Writer {
|
||||
fn blocks(&mut self, blocks: &[FBlock]) {
|
||||
let mut lists: Vec<bool> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(b) = blocks.get(i) {
|
||||
let list = if let FBlock::Para(p) = b { p.list } else { None };
|
||||
match list {
|
||||
Some(li) => {
|
||||
let depth = (li.level.min(3) + 1) as usize;
|
||||
while lists.len() > depth || (lists.len() == depth && lists.last() != Some(&li.ordered)) {
|
||||
self.close_list(&mut lists);
|
||||
}
|
||||
while lists.len() < depth {
|
||||
let env = if li.ordered { "enumerate" } else { "itemize" };
|
||||
self.out.push_str(&format!("{}\\begin{{{env}}}\n", " ".repeat(lists.len())));
|
||||
lists.push(li.ordered);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
while !lists.is_empty() {
|
||||
self.close_list(&mut lists);
|
||||
}
|
||||
}
|
||||
}
|
||||
match b {
|
||||
FBlock::Para(p) if p.list.is_some() => {
|
||||
let text = self.inlines(&p.inlines);
|
||||
self.page_break(p);
|
||||
self.out.push_str(&format!("{}\\item {}\n", " ".repeat(lists.len()), text.trim()));
|
||||
}
|
||||
FBlock::Para(p) if p.kind == Kind::Code => {
|
||||
let mut j = i;
|
||||
let mut lines = Vec::new();
|
||||
while let Some(FBlock::Para(q)) = blocks.get(j) {
|
||||
if q.kind != Kind::Code || q.list.is_some() {
|
||||
break;
|
||||
}
|
||||
lines.push(q.text());
|
||||
j += 1;
|
||||
}
|
||||
self.page_break(p);
|
||||
self.code(&lines);
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
FBlock::Para(p) if p.kind == Kind::Quote => {
|
||||
let mut j = i;
|
||||
let mut paras = Vec::new();
|
||||
while let Some(FBlock::Para(q)) = blocks.get(j) {
|
||||
if q.kind != Kind::Quote || q.list.is_some() {
|
||||
break;
|
||||
}
|
||||
paras.push(self.inlines(&q.inlines));
|
||||
j += 1;
|
||||
}
|
||||
self.page_break(p);
|
||||
let body: Vec<&str> = paras.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
let sep = if self.in_cell { "\\par\n" } else { "\n\n" };
|
||||
self.out.push_str(&format!("\\begin{{quote}}\n{}\n\\end{{quote}}\n\n", body.join(sep)));
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
FBlock::Para(p) => {
|
||||
let top_title = self.depth == 0 && self.title_at == Some(i);
|
||||
self.para(p, top_title);
|
||||
}
|
||||
FBlock::Table(t) => self.table(t),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
while !lists.is_empty() {
|
||||
self.close_list(&mut lists);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_list(&mut self, lists: &mut Vec<bool>) {
|
||||
if let Some(ordered) = lists.pop() {
|
||||
let env = if ordered { "enumerate" } else { "itemize" };
|
||||
self.out.push_str(&format!("{}\\end{{{env}}}\n", " ".repeat(lists.len())));
|
||||
if lists.is_empty() && !self.in_cell {
|
||||
self.out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn page_break(&mut self, p: &Para) {
|
||||
if p.page_break && !self.in_cell {
|
||||
self.out.push_str("\\clearpage\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn para(&mut self, p: &Para, top_title: bool) {
|
||||
self.page_break(p);
|
||||
if p.kind == Kind::Rule {
|
||||
self.out.push_str("\\noindent\\rule{\\linewidth}{0.4pt}\n\n");
|
||||
return;
|
||||
}
|
||||
if top_title {
|
||||
self.out.push_str("\\maketitle\n\n");
|
||||
return;
|
||||
}
|
||||
let text = self.inlines(&p.inlines);
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let end = if self.in_cell { "\\par\n" } else { "\n\n" };
|
||||
match p.kind {
|
||||
Kind::Heading(n) if !self.in_cell => {
|
||||
let cmd = SECTIONS.get(usize::from(n.clamp(1, 6)) - 1).copied().unwrap_or("section");
|
||||
// `\\` (a line break) isn't allowed in a heading.
|
||||
let text = text.replace("\\\\\n", " ");
|
||||
self.out.push_str(&format!("\\{cmd}*{{{text}}}\n\n"));
|
||||
}
|
||||
Kind::Title => self.out.push_str(&format!("\\begin{{center}}\n{{\\LARGE {text}\\par}}\n\\end{{center}}\n\n")),
|
||||
Kind::Heading(_) => self.out.push_str(&format!("\\textbf{{{text}}}{end}")),
|
||||
_ => match p.align {
|
||||
Some(Align::Center) => self.out.push_str(&format!("\\begin{{center}}\n{text}\n\\end{{center}}\n\n")),
|
||||
Some(Align::Right) => self.out.push_str(&format!("\\begin{{flushright}}\n{text}\n\\end{{flushright}}\n\n")),
|
||||
_ => {
|
||||
self.out.push_str(text);
|
||||
self.out.push_str(end);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&mut self, lines: &[String]) {
|
||||
if self.in_cell {
|
||||
// `verbatim` can't go in a table cell: typewriter lines instead.
|
||||
let v: Vec<String> = lines.iter().map(|l| format!("\\texttt{{{}}}", escape_text(&l.replace(' ', "\u{A0}")))).collect();
|
||||
self.out.push_str(&v.join("\\\\\n"));
|
||||
self.out.push_str("\\par\n");
|
||||
return;
|
||||
}
|
||||
self.out.push_str("\\begin{verbatim}\n");
|
||||
for l in lines {
|
||||
// The environment ends at the first `\end{verbatim}`, so keep it from appearing. The
|
||||
// control characters go first: removing one afterwards could join a delimiter back up.
|
||||
let l: String = l.chars().filter(|c| *c == '\t' || !c.is_control()).collect();
|
||||
let l = l.replace("\\end{verbatim}", "\\end {verbatim}");
|
||||
self.out.push_str(&l);
|
||||
self.out.push('\n');
|
||||
}
|
||||
self.out.push_str("\\end{verbatim}\n\n");
|
||||
}
|
||||
|
||||
/// LaTeX for a run of inlines.
|
||||
fn inlines(&mut self, inlines: &[Inline]) -> String {
|
||||
let mut o = String::new();
|
||||
for i in inlines {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
let s = self.span(t, f);
|
||||
push_apart(&mut o, &s);
|
||||
}
|
||||
Inline::Image(img) => {
|
||||
let alt = if img.alt.trim().is_empty() { "Picture".to_string() } else { format!("Picture: {}", img.alt.trim()) };
|
||||
o.push_str(&format!("\\textit{{[{}]}}", escape_text(&alt)));
|
||||
}
|
||||
Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))),
|
||||
Inline::Figure(_)
|
||||
| Inline::Chart(_)
|
||||
| Inline::Note { .. }
|
||||
| Inline::Comment { .. }
|
||||
| Inline::CommentEnd(_)
|
||||
| Inline::Shape { .. }
|
||||
| Inline::Group { .. }
|
||||
| Inline::FieldStart(_)
|
||||
| Inline::FieldEnd
|
||||
| Inline::AnchorEnd(_) => {}
|
||||
// ASK and SET show nothing (see `model::shows_nothing`).
|
||||
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
|
||||
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => {
|
||||
let s = self.span(result, fmt);
|
||||
push_apart(&mut o, &s);
|
||||
}
|
||||
// LaTeX keeps no ruby (no package): its base text, in the run's formatting.
|
||||
Inline::Ruby { base, fmt, .. } => {
|
||||
let s = self.span(base, fmt);
|
||||
push_apart(&mut o, &s);
|
||||
}
|
||||
Inline::Equation { linear, display } => {
|
||||
self.uses.math = true;
|
||||
let src = linear_to_latex(linear);
|
||||
if *display && !self.in_cell {
|
||||
o.push_str(&format!("\n\\[ {src} \\]\n"));
|
||||
} else {
|
||||
o.push_str(&format!("${src}$"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn span(&mut self, text: &str, f: &Fmt) -> String {
|
||||
let mut s = if f.code { escape_text(&text.replace(' ', "\u{A0}")) } else { escape_text(text) };
|
||||
if s.is_empty() {
|
||||
return s;
|
||||
}
|
||||
let wrap = |s: String, cmd: &str| format!("\\{cmd}{{{s}}}");
|
||||
if f.code {
|
||||
s = wrap(s, "texttt");
|
||||
}
|
||||
if f.sup {
|
||||
s = wrap(s, "textsuperscript");
|
||||
} else if f.sub {
|
||||
s = wrap(s, "textsubscript");
|
||||
}
|
||||
if f.bold {
|
||||
s = wrap(s, "textbf");
|
||||
}
|
||||
if f.italic {
|
||||
s = wrap(s, "textit");
|
||||
}
|
||||
if f.underline {
|
||||
self.uses.ulem = true;
|
||||
s = wrap(s, "uline");
|
||||
}
|
||||
if f.strike {
|
||||
self.uses.ulem = true;
|
||||
s = wrap(s, "sout");
|
||||
}
|
||||
if let Some(c) = f.color.filter(|c| *c != Rgb(0, 0, 0)) {
|
||||
self.uses.color = true;
|
||||
s = format!("\\textcolor[HTML]{{{}}}{{{s}}}", hex(c));
|
||||
}
|
||||
if let Some(size) = f.size.filter(|v| v.is_finite() && *v >= 1.0 && *v <= 1638.0) {
|
||||
s = format!("{{\\fontsize{{{}}}{{{}}}\\selectfont {s}}}", num(size), num(size * 1.2));
|
||||
}
|
||||
if let Some(l) = f.link.as_deref().filter(|l| !l.is_empty()) {
|
||||
s = match l.strip_prefix('#') {
|
||||
// An internal bookmark reference, not a URL.
|
||||
Some(target) => format!("\\hyperlink{{{}}}{{{s}}}", escape_url(target)),
|
||||
// A script, `file:`, UNC or other non-web target becomes plain text: a `\href`
|
||||
// compiles to a PDF `/URI` action the reader would otherwise follow on click.
|
||||
None if wordcraft_doc::link::is_safe_href(l) => format!("\\href{{{}}}{{{s}}}", escape_url(l)),
|
||||
None => s,
|
||||
};
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub(super) fn table(&mut self, t: &FTable) {
|
||||
let cols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
let widths: Vec<f32> = if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 1.0) {
|
||||
let total: f32 = t.widths.iter().sum();
|
||||
// Scale a table wider than the page to fit.
|
||||
let k = if total > LINE_WIDTH { LINE_WIDTH / total } else { 1.0 };
|
||||
t.widths.iter().map(|w| w * k).collect()
|
||||
} else {
|
||||
vec![LINE_WIDTH / cols as f32; cols]
|
||||
};
|
||||
let col = |w: f32| format!("p{{{}pt}}", num((w - 2.0 * TABCOLSEP).max(12.0)));
|
||||
let spec: String = if t.borderless {
|
||||
widths.iter().map(|w| col(*w)).collect::<Vec<_>>().join(" ")
|
||||
} else {
|
||||
let s: String = widths.iter().map(|w| format!("{}|", col(*w))).collect();
|
||||
format!("|{s}")
|
||||
};
|
||||
let mut o = if t.borderless {
|
||||
format!("\\noindent\\begin{{tabular}}{{{spec}}}\n")
|
||||
} else {
|
||||
format!("\\noindent\\begin{{tabular}}{{{spec}}}\n\\hline\n")
|
||||
};
|
||||
for row in &t.rows {
|
||||
let mut cells = Vec::new();
|
||||
let mut g = 0usize;
|
||||
for c in row {
|
||||
let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1));
|
||||
let w: f32 = widths.get(g..(g + span).min(widths.len())).map(|s| s.iter().sum()).unwrap_or(72.0);
|
||||
let mut body = if c.covered { String::new() } else { self.cell(c) };
|
||||
if c.rowspan > 1 && !c.covered {
|
||||
self.uses.multirow = true;
|
||||
body = format!("\\multirow{{{}}}{{=}}{{{body}}}", c.rowspan.min(1000));
|
||||
}
|
||||
if let Some(sh) = c.shading {
|
||||
self.uses.color = true;
|
||||
body = format!("\\cellcolor[HTML]{{{}}}{body}", hex(sh));
|
||||
}
|
||||
if span > 1 {
|
||||
let col_spec = if t.borderless {
|
||||
col(w)
|
||||
} else {
|
||||
let left = if g == 0 { "|" } else { "" };
|
||||
format!("{left}{}|", col(w))
|
||||
};
|
||||
body = format!("\\multicolumn{{{span}}}{{{col_spec}}}{{{body}}}");
|
||||
}
|
||||
cells.push(body);
|
||||
g += span;
|
||||
}
|
||||
if cells.is_empty() {
|
||||
continue;
|
||||
}
|
||||
o.push_str(&cells.join(" & "));
|
||||
if t.borderless {
|
||||
o.push_str(" \\\\\n");
|
||||
} else {
|
||||
o.push_str(" \\\\\n\\hline\n");
|
||||
}
|
||||
}
|
||||
o.push_str("\\end{tabular}\n");
|
||||
self.out.push_str(&o);
|
||||
self.out.push_str(if self.in_cell { "\\par\n" } else { "\n" });
|
||||
}
|
||||
|
||||
fn cell(&mut self, c: &Cell) -> String {
|
||||
let mut sub = Writer { in_cell: true, depth: self.depth + 1, ..Default::default() };
|
||||
if self.depth < model::MAX_DEPTH {
|
||||
sub.blocks(&c.blocks);
|
||||
} else {
|
||||
sub.out = escape_text(&c.text());
|
||||
}
|
||||
self.uses.ulem |= sub.uses.ulem;
|
||||
self.uses.color |= sub.uses.color;
|
||||
self.uses.multirow |= sub.uses.multirow;
|
||||
self.uses.math |= sub.uses.math;
|
||||
let s = sub.out.trim().trim_end_matches("\\par").trim_end().to_string();
|
||||
s.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(c: Rgb) -> String {
|
||||
format!("{:02X}{:02X}{:02X}", c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
/// A length without needless decimals.
|
||||
fn num(v: f32) -> String {
|
||||
let r = (v * 100.0).round() / 100.0;
|
||||
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r}") }
|
||||
}
|
||||
|
||||
/// Appends a run, writing `{}` between a `-` or `'` ending `o` and the same character starting
|
||||
/// `s`, so TeX does not join them across runs into a dash or quote.
|
||||
fn push_apart(o: &mut String, s: &str) {
|
||||
if let Some(c) = s.chars().next()
|
||||
&& (c == '-' || c == '\'')
|
||||
&& o.ends_with(c)
|
||||
{
|
||||
o.push_str("{}");
|
||||
}
|
||||
o.push_str(s);
|
||||
}
|
||||
|
||||
/// Text with LaTeX's special characters escaped.
|
||||
pub fn escape_text(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 8);
|
||||
let mut prev = '\0';
|
||||
let mut line_has_text = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => o.push_str("\\textbackslash{}"),
|
||||
'{' => o.push_str("\\{"),
|
||||
'}' => o.push_str("\\}"),
|
||||
'$' | '&' | '#' | '%' | '_' => {
|
||||
o.push('\\');
|
||||
o.push(c);
|
||||
}
|
||||
'~' => o.push_str("\\textasciitilde{}"),
|
||||
'^' => o.push_str("\\textasciicircum{}"),
|
||||
'<' => o.push_str("\\textless{}"),
|
||||
'>' => o.push_str("\\textgreater{}"),
|
||||
'|' => o.push_str("\\textbar{}"),
|
||||
'\u{A0}' => o.push('~'),
|
||||
// TeX collapses a run of spaces into one; a control space keeps each further one.
|
||||
' ' if prev == ' ' => o.push_str("\\ "),
|
||||
// An opening bracket here would become an optional item or line-break argument.
|
||||
'[' if !line_has_text => o.push_str("{[}"),
|
||||
// Keep TeX from joining `--`, ``` `` ``` and `''` into dashes and quotes.
|
||||
'-' if prev == '-' => o.push_str("{}-"),
|
||||
'`' => o.push_str("\\textasciigrave{}"),
|
||||
'\'' if prev == '\'' => o.push_str("{}'"),
|
||||
'\t' => o.push_str("\\quad{}"),
|
||||
'\n' | '\u{000B}' => {
|
||||
// A line break; LaTeX refuses one with nothing before it on the line.
|
||||
o.push_str(if line_has_text { "\\\\\n" } else { "\\mbox{}\\\\\n" });
|
||||
line_has_text = false;
|
||||
prev = c;
|
||||
continue;
|
||||
}
|
||||
c if c.is_control() => {}
|
||||
c => o.push(c),
|
||||
}
|
||||
if !c.is_whitespace() {
|
||||
line_has_text = true;
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// A URL or anchor name for `\href`/`\hyperlink`: `%`, `#`, `\`, braces escaped.
|
||||
fn escape_url(s: &str) -> String {
|
||||
let mut o = String::new();
|
||||
for c in s.chars().filter(|c| !c.is_control()) {
|
||||
// Never `^^`: TeX reads `^^5c` as a backslash, which would start a command.
|
||||
if c == '^' && o.ends_with('^') {
|
||||
continue;
|
||||
}
|
||||
if matches!(c, '%' | '#' | '\\' | '{' | '}') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Tok {
|
||||
/// `\name` (a starred form keeps its `*`) or a control symbol like `\&`.
|
||||
Cmd(String),
|
||||
Open,
|
||||
Close,
|
||||
/// Text (spaces collapsed to one).
|
||||
Text(String),
|
||||
/// A blank line.
|
||||
Par,
|
||||
Amp,
|
||||
/// `~`: a non-breaking space.
|
||||
Tie,
|
||||
/// Math source, and whether it is displayed.
|
||||
Math(String, bool),
|
||||
/// The content of a verbatim-like environment.
|
||||
Verbatim(String),
|
||||
/// `\verb|…|`.
|
||||
Verb(String),
|
||||
/// `\begin{name}` and `\end{name}`.
|
||||
Begin(String),
|
||||
End(String),
|
||||
}
|
||||
|
||||
const VERBATIM_ENVS: &[&str] = &["verbatim", "verbatim*", "Verbatim", "lstlisting", "minted", "alltt", "comment"];
|
||||
|
||||
const MATH_ENVS: &[&str] = &[
|
||||
"equation",
|
||||
"equation*",
|
||||
"align",
|
||||
"align*",
|
||||
"alignat",
|
||||
"alignat*",
|
||||
"gather",
|
||||
"gather*",
|
||||
"multline",
|
||||
"multline*",
|
||||
"flalign",
|
||||
"flalign*",
|
||||
"displaymath",
|
||||
"math",
|
||||
"eqnarray",
|
||||
"eqnarray*",
|
||||
];
|
||||
|
||||
struct Lexer<'a> {
|
||||
s: &'a str,
|
||||
i: usize,
|
||||
out: Vec<Tok>,
|
||||
}
|
||||
|
||||
impl Lexer<'_> {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.s.get(self.i..).and_then(|r| r.chars().next())
|
||||
}
|
||||
fn rest(&self) -> &str {
|
||||
self.s.get(self.i..).unwrap_or("")
|
||||
}
|
||||
fn bump(&mut self) -> Option<char> {
|
||||
let c = self.peek()?;
|
||||
self.i += c.len_utf8();
|
||||
Some(c)
|
||||
}
|
||||
fn text(&mut self, t: &str) {
|
||||
if let Some(Tok::Text(s)) = self.out.last_mut() {
|
||||
s.push_str(t);
|
||||
} else {
|
||||
self.out.push(Tok::Text(t.to_string()));
|
||||
}
|
||||
}
|
||||
/// Skip the rest of a line (a comment), the line end and the next line's leading blanks.
|
||||
fn comment(&mut self) {
|
||||
while let Some(c) = self.bump() {
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while matches!(self.peek(), Some(' ' | '\t')) {
|
||||
self.bump();
|
||||
}
|
||||
}
|
||||
/// Read until `end` (not included; consumed) and return what came before.
|
||||
fn until(&mut self, end: &str) -> String {
|
||||
match self.rest().find(end) {
|
||||
Some(at) => {
|
||||
let s = self.rest().get(..at).unwrap_or("").to_string();
|
||||
self.i += at + end.len();
|
||||
s
|
||||
}
|
||||
None => {
|
||||
let s = self.rest().to_string();
|
||||
self.i = self.s.len();
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
/// After `\begin` / `\end`: the `{name}`, if well formed.
|
||||
fn env_name(&mut self) -> Option<String> {
|
||||
let save = self.i;
|
||||
while matches!(self.peek(), Some(' ' | '\t')) {
|
||||
self.bump();
|
||||
}
|
||||
if self.peek() != Some('{') {
|
||||
self.i = save;
|
||||
return None;
|
||||
}
|
||||
self.bump();
|
||||
// Only the next 65 bytes are searched: looking through the rest of the file for a `}`
|
||||
// made a run of unclosed `\\begin{` quadratic.
|
||||
let Some(end) = self.rest().bytes().take(65).position(|b| b == b'}') else {
|
||||
// Unclosed or overlong: leave the `{` to be read as ordinary input.
|
||||
self.i = save;
|
||||
return None;
|
||||
};
|
||||
let r = self.rest();
|
||||
let name = r.get(..end).unwrap_or("").trim().to_string();
|
||||
self.i += end + 1;
|
||||
Some(name)
|
||||
}
|
||||
|
||||
fn run(mut self) -> Vec<Tok> {
|
||||
while let Some(c) = self.peek() {
|
||||
match c {
|
||||
'\\' => {
|
||||
self.bump();
|
||||
let r = self.rest();
|
||||
let n = r.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(r.len());
|
||||
if n == 0 {
|
||||
let Some(sym) = self.bump() else { break };
|
||||
match sym {
|
||||
'(' => {
|
||||
let m = self.until("\\)");
|
||||
self.out.push(Tok::Math(m, false));
|
||||
}
|
||||
'[' => {
|
||||
let m = self.until("\\]");
|
||||
self.out.push(Tok::Math(m, true));
|
||||
}
|
||||
_ => self.out.push(Tok::Cmd(sym.to_string())),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut name = r.get(..n).unwrap_or("").to_string();
|
||||
self.i += n;
|
||||
if self.peek() == Some('*') {
|
||||
self.bump();
|
||||
name.push('*');
|
||||
}
|
||||
match name.as_str() {
|
||||
"begin" => match self.env_name() {
|
||||
Some(env) if VERBATIM_ENVS.contains(&env.as_str()) => {
|
||||
// `minted{lang}` / `lstlisting[options]`: skip to the line end.
|
||||
let _options = self.until("\n");
|
||||
let v = self.until(&format!("\\end{{{env}}}"));
|
||||
if env != "comment" {
|
||||
self.out.push(Tok::Verbatim(v));
|
||||
}
|
||||
}
|
||||
Some(env) if MATH_ENVS.contains(&env.as_str()) => {
|
||||
let m = self.until(&format!("\\end{{{env}}}"));
|
||||
self.out.push(Tok::Math(m, env != "math"));
|
||||
}
|
||||
Some(env) => self.out.push(Tok::Begin(env)),
|
||||
None => self.out.push(Tok::Cmd(name)),
|
||||
},
|
||||
"end" => match self.env_name() {
|
||||
Some(env) => self.out.push(Tok::End(env)),
|
||||
None => self.out.push(Tok::Cmd(name)),
|
||||
},
|
||||
"verb" | "verb*" => match self.bump() {
|
||||
Some(delim) if !delim.is_alphabetic() && !delim.is_whitespace() => {
|
||||
let v = self.until(&delim.to_string());
|
||||
self.out.push(Tok::Verb(v));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {
|
||||
self.out.push(Tok::Cmd(name));
|
||||
// A control word swallows the spaces (and one line end) after it.
|
||||
let mut newline = false;
|
||||
while let Some(c) = self.peek() {
|
||||
if c == ' ' || c == '\t' || (c == '\n' && !newline) {
|
||||
newline |= c == '\n';
|
||||
self.bump();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'%' => self.comment(),
|
||||
'{' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Open);
|
||||
}
|
||||
'}' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Close);
|
||||
}
|
||||
'&' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Amp);
|
||||
}
|
||||
'~' => {
|
||||
self.bump();
|
||||
self.out.push(Tok::Tie);
|
||||
}
|
||||
'$' => {
|
||||
self.bump();
|
||||
if self.peek() == Some('$') {
|
||||
self.bump();
|
||||
let m = self.until("$$");
|
||||
self.out.push(Tok::Math(m, true));
|
||||
} else {
|
||||
let m = self.until("$");
|
||||
self.out.push(Tok::Math(m, false));
|
||||
}
|
||||
}
|
||||
'\n' | '\r' | ' ' | '\t' => {
|
||||
// Whitespace: a blank line ends the paragraph, anything else is one space.
|
||||
let mut lines = 0;
|
||||
while let Some(c) = self.peek() {
|
||||
match c {
|
||||
'\n' => lines += 1,
|
||||
' ' | '\t' | '\r' => {}
|
||||
_ => break,
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
if lines >= 2 {
|
||||
self.out.push(Tok::Par);
|
||||
} else {
|
||||
self.text(" ");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let r = self.rest();
|
||||
let n = r.find(['\\', '%', '{', '}', '&', '~', '$', '\n', '\r', ' ', '\t']).unwrap_or(r.len()).max(c.len_utf8());
|
||||
let t = r.get(..n).unwrap_or("").to_string();
|
||||
self.i += n;
|
||||
self.text(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tokenize(src: &str) -> Vec<Tok> {
|
||||
Lexer { s: src, i: 0, out: Vec::new() }.run()
|
||||
}
|
||||
|
||||
/// TeX's text ligatures: dashes and quotes.
|
||||
pub(super) fn ligatures(s: &str) -> String {
|
||||
s.replace("---", "—").replace("--", "–").replace("``", "“").replace("''", "”").replace('`', "‘").replace("<<", "«").replace(">>", "»")
|
||||
}
|
||||
|
||||
/// Character for a symbol command, if it is one.
|
||||
pub(super) fn text_symbol(name: &str) -> Option<&'static str> {
|
||||
Some(match name {
|
||||
"&" => "&",
|
||||
"%" => "%",
|
||||
"$" => "$",
|
||||
"#" => "#",
|
||||
"_" => "_",
|
||||
"{" => "{",
|
||||
"}" => "}",
|
||||
" " => " ",
|
||||
"," | ";" | ":" | "enspace" | "thinspace" => " ",
|
||||
"quad" => "\u{2003}",
|
||||
"qquad" => "\u{2003}\u{2003}",
|
||||
"-" | "/" | "@" | "!" | "noindent" | "indent" | "relax" | "protect" | "selectfont" | "nobreak" | "sloppy" | "fussy" | "centering"
|
||||
| "raggedright" | "raggedleft" | "normalfont" | "item" => "",
|
||||
"textbackslash" | "backslash" => "\\",
|
||||
"textasciitilde" => "~",
|
||||
"textasciicircum" => "^",
|
||||
"textless" => "<",
|
||||
"textgreater" => ">",
|
||||
"textbar" => "|",
|
||||
"textbraceleft" => "{",
|
||||
"textbraceright" => "}",
|
||||
"textunderscore" => "_",
|
||||
"textasciigrave" => "`",
|
||||
"textquotesingle" => "'",
|
||||
"textquotedbl" => "\"",
|
||||
"ldots" | "dots" | "textellipsis" => "…",
|
||||
"textendash" => "–",
|
||||
"textemdash" => "—",
|
||||
"textbullet" => "•",
|
||||
"textperiodcentered" => "·",
|
||||
"textquoteleft" => "‘",
|
||||
"textquoteright" => "’",
|
||||
"textquotedblleft" => "“",
|
||||
"textquotedblright" => "”",
|
||||
"guillemotleft" | "guillemetleft" => "«",
|
||||
"guillemotright" | "guillemetright" => "»",
|
||||
"copyright" | "textcopyright" => "©",
|
||||
"textregistered" => "®",
|
||||
"texttrademark" => "™",
|
||||
"S" | "textsection" => "§",
|
||||
"P" | "textparagraph" => "¶",
|
||||
"dag" | "textdagger" => "†",
|
||||
"ddag" | "textdaggerdbl" => "‡",
|
||||
"pounds" | "textsterling" => "£",
|
||||
"euro" | "texteuro" => "€",
|
||||
"textyen" => "¥",
|
||||
"textcent" => "¢",
|
||||
"textdegree" | "degree" => "°",
|
||||
"textmu" => "µ",
|
||||
"texttimes" => "×",
|
||||
"textdiv" => "÷",
|
||||
"textpm" => "±",
|
||||
"textonehalf" => "½",
|
||||
"textonequarter" => "¼",
|
||||
"textthreequarters" => "¾",
|
||||
"textexclamdown" => "¡",
|
||||
"textquestiondown" => "¿",
|
||||
"ss" => "ß",
|
||||
"SS" => "SS",
|
||||
"ae" => "æ",
|
||||
"AE" => "Æ",
|
||||
"oe" => "œ",
|
||||
"OE" => "Œ",
|
||||
"o" => "ø",
|
||||
"O" => "Ø",
|
||||
"aa" => "å",
|
||||
"AA" => "Å",
|
||||
"l" => "ł",
|
||||
"L" => "Ł",
|
||||
"i" => "ı",
|
||||
"j" => "ȷ",
|
||||
"LaTeX" => "LaTeX",
|
||||
"LaTeXe" => "LaTeX2ε",
|
||||
"TeX" => "TeX",
|
||||
"XeTeX" => "XeTeX",
|
||||
"LuaTeX" => "LuaTeX",
|
||||
"BibTeX" => "BibTeX",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Combining mark for an accent command.
|
||||
pub(super) fn accent_mark(name: &str) -> Option<char> {
|
||||
Some(match name {
|
||||
"'" => '\u{301}',
|
||||
"`" => '\u{300}',
|
||||
"^" => '\u{302}',
|
||||
"\"" => '\u{308}',
|
||||
"~" => '\u{303}',
|
||||
"=" => '\u{304}',
|
||||
"." => '\u{307}',
|
||||
"u" => '\u{306}',
|
||||
"v" => '\u{30C}',
|
||||
"H" => '\u{30B}',
|
||||
"c" => '\u{327}',
|
||||
"k" => '\u{328}',
|
||||
"r" => '\u{30A}',
|
||||
"d" => '\u{323}',
|
||||
"b" => '\u{331}',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Precomposed letters for the common accents (base + combining mark otherwise).
|
||||
pub(super) fn compose(base: char, mark: char) -> String {
|
||||
const TABLE: &[(char, char, char)] = &[
|
||||
('a', '\u{301}', 'á'),
|
||||
('e', '\u{301}', 'é'),
|
||||
('i', '\u{301}', 'í'),
|
||||
('ı', '\u{301}', 'í'),
|
||||
('o', '\u{301}', 'ó'),
|
||||
('u', '\u{301}', 'ú'),
|
||||
('y', '\u{301}', 'ý'),
|
||||
('c', '\u{301}', 'ć'),
|
||||
('n', '\u{301}', 'ń'),
|
||||
('s', '\u{301}', 'ś'),
|
||||
('z', '\u{301}', 'ź'),
|
||||
('A', '\u{301}', 'Á'),
|
||||
('E', '\u{301}', 'É'),
|
||||
('I', '\u{301}', 'Í'),
|
||||
('O', '\u{301}', 'Ó'),
|
||||
('U', '\u{301}', 'Ú'),
|
||||
('Y', '\u{301}', 'Ý'),
|
||||
('a', '\u{300}', 'à'),
|
||||
('e', '\u{300}', 'è'),
|
||||
('i', '\u{300}', 'ì'),
|
||||
('ı', '\u{300}', 'ì'),
|
||||
('o', '\u{300}', 'ò'),
|
||||
('u', '\u{300}', 'ù'),
|
||||
('A', '\u{300}', 'À'),
|
||||
('E', '\u{300}', 'È'),
|
||||
('I', '\u{300}', 'Ì'),
|
||||
('O', '\u{300}', 'Ò'),
|
||||
('U', '\u{300}', 'Ù'),
|
||||
('a', '\u{302}', 'â'),
|
||||
('e', '\u{302}', 'ê'),
|
||||
('i', '\u{302}', 'î'),
|
||||
('ı', '\u{302}', 'î'),
|
||||
('o', '\u{302}', 'ô'),
|
||||
('u', '\u{302}', 'û'),
|
||||
('A', '\u{302}', 'Â'),
|
||||
('E', '\u{302}', 'Ê'),
|
||||
('I', '\u{302}', 'Î'),
|
||||
('O', '\u{302}', 'Ô'),
|
||||
('U', '\u{302}', 'Û'),
|
||||
('a', '\u{308}', 'ä'),
|
||||
('e', '\u{308}', 'ë'),
|
||||
('i', '\u{308}', 'ï'),
|
||||
('ı', '\u{308}', 'ï'),
|
||||
('o', '\u{308}', 'ö'),
|
||||
('u', '\u{308}', 'ü'),
|
||||
('y', '\u{308}', 'ÿ'),
|
||||
('A', '\u{308}', 'Ä'),
|
||||
('E', '\u{308}', 'Ë'),
|
||||
('I', '\u{308}', 'Ï'),
|
||||
('O', '\u{308}', 'Ö'),
|
||||
('U', '\u{308}', 'Ü'),
|
||||
('a', '\u{303}', 'ã'),
|
||||
('n', '\u{303}', 'ñ'),
|
||||
('o', '\u{303}', 'õ'),
|
||||
('A', '\u{303}', 'Ã'),
|
||||
('N', '\u{303}', 'Ñ'),
|
||||
('O', '\u{303}', 'Õ'),
|
||||
('c', '\u{327}', 'ç'),
|
||||
('C', '\u{327}', 'Ç'),
|
||||
('s', '\u{327}', 'ş'),
|
||||
('S', '\u{327}', 'Ş'),
|
||||
('a', '\u{30A}', 'å'),
|
||||
('A', '\u{30A}', 'Å'),
|
||||
('u', '\u{30A}', 'ů'),
|
||||
('c', '\u{30C}', 'č'),
|
||||
('d', '\u{30C}', 'ď'),
|
||||
('e', '\u{30C}', 'ě'),
|
||||
('n', '\u{30C}', 'ň'),
|
||||
('r', '\u{30C}', 'ř'),
|
||||
('s', '\u{30C}', 'š'),
|
||||
('t', '\u{30C}', 'ť'),
|
||||
('z', '\u{30C}', 'ž'),
|
||||
('C', '\u{30C}', 'Č'),
|
||||
('D', '\u{30C}', 'Ď'),
|
||||
('E', '\u{30C}', 'Ě'),
|
||||
('N', '\u{30C}', 'Ň'),
|
||||
('R', '\u{30C}', 'Ř'),
|
||||
('S', '\u{30C}', 'Š'),
|
||||
('T', '\u{30C}', 'Ť'),
|
||||
('Z', '\u{30C}', 'Ž'),
|
||||
('g', '\u{306}', 'ğ'),
|
||||
('G', '\u{306}', 'Ğ'),
|
||||
('o', '\u{30B}', 'ő'),
|
||||
('u', '\u{30B}', 'ű'),
|
||||
('O', '\u{30B}', 'Ő'),
|
||||
('U', '\u{30B}', 'Ű'),
|
||||
('a', '\u{328}', 'ą'),
|
||||
('e', '\u{328}', 'ę'),
|
||||
('A', '\u{328}', 'Ą'),
|
||||
('E', '\u{328}', 'Ę'),
|
||||
('z', '\u{307}', 'ż'),
|
||||
('Z', '\u{307}', 'Ż'),
|
||||
('a', '\u{304}', 'ā'),
|
||||
('e', '\u{304}', 'ē'),
|
||||
('i', '\u{304}', 'ī'),
|
||||
('o', '\u{304}', 'ō'),
|
||||
('u', '\u{304}', 'ū'),
|
||||
];
|
||||
match TABLE.iter().find(|(b, m, _)| *b == base && *m == mark) {
|
||||
Some((_, _, c)) => c.to_string(),
|
||||
None => format!("{base}{mark}"),
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/// Largest `.tex` source imported (bytes); longer input is cut off.
|
||||
pub const MAX_SOURCE: usize = 32 << 20;
|
||||
|
||||
/// Nesting limit for groups, environments and arguments (hostile input).
|
||||
pub(super) const MAX_NEST: usize = 32;
|
||||
|
||||
/// Longest `tabular` column spec read (characters); the rest is ignored.
|
||||
pub(super) const MAX_SPEC: usize = 4096;
|
||||
|
||||
/// Most bookmarks kept in a row with no text between them.
|
||||
pub(super) const MAX_ANCHOR_RUN: usize = 64;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user