Compare commits

..
Author SHA1 Message Date
FredApps 2cde91a869 Release: WordCraft v0.19.0 (main 323cf78232) 2026-11-14 21:48:03 +01:00
FredApps 76e5732355 Release: WordCraft v0.19.0 2026-11-14 21:48:00 +01:00
batistalucasv 323cf78232 UI: open font dropdown and select font name on click (#5914) (#5926)
* UI: open font dropdown and select font name on click (#5914)

* UI: select font name only on opening dropdown and scope to search (#5914)

* UI: refresh highlight from current font whenever combo opens (#5914)

* UI: restore font order and match enter ordering for highlight (#5914)

* UI: address review comments on font dropdown interactions and docs (#5914)

* UI: handle system font action in dialog font combos and test focus selection (#5914)

* UI: reset highlight on open and preserve query on scrollbar focus (#5914)

* UI: drop dialog routing changes to keep scope narrow (#5914)

* UI: test genuine blur and refocus while font dropdown is open (#5914)
2026-11-14 21:22:27 +01:00
Dylan M. Taylor 42a5df5f94 chore: cut over image codecs and zip to craft-libs (#6661)
* chore: cut over image codecs and zip to craft-libs, fix main test regressions

* tests: store ODS mimetype and use standard ODF namespaces in mailings test

* Remove unused quick-xml workspace dependency

* fix: use standard ODF namespaces in file_dialogs test and preserve trailing line in markdown import_on_with_lines

* fix(geom): use png_chunks for PNG dimensions and p.fits(budget) in decode_for_drawing
2026-11-14 14:51:35 -05:00
FredApps f8c66d0692 Merge pull request #6040 from FredApps/fix/artcraft-feature-names-20261108
Use Diagram and Decorative Text display names
2026-11-14 16:22:19 +01:00
FredApps aba890e8a6 fix(i18n): align Flatpak catalog pin with embedded catalogs 2026-11-14 14:05:33 +01:00
FredApps 0cc92c9744 #6040: pin merged feature catalogs and handle escaped character literals 2026-11-14 13:29:49 +01:00
FredApps b02baeba43 Merge pull request #2274 from FredApps/fix/1455-format-painter
Apply Format Painter on single-click release (#1455)
2026-11-14 12:16:34 +01:00
FredApps 3c77297b83 Merge branch 'main' into fix/artcraft-feature-names-20261108
# Conflicts:
#	crates/engine/src/tests/wordart.rs
2026-11-14 11:36:36 +01:00
Dylan M. Taylor 60415e055c fix(xtask): allow wordcraft-formats -> wordcraft-docx in INTRA_LAYER_ORDER (#6618) 2026-11-14 02:51:29 -05:00
Dylan M. Taylor 0db15a1d5e Merge pull request #6617 from storytold/fix/alt-chunks-missing-pictures-total
fix(docx): fix main CI wordcraft-docx integration test failures
2026-11-14 01:16:29 -05:00
Dylan M. Taylor 33afe69094 fix(docx): bump craft-docx/wordmodel, fix missing_pictures total, and dedup Arc<Graphic> 2026-11-14 06:16:12 +00:00
Dylan M. Taylor 669d5df0b1 Merge pull request #6612 from storytold/codex/fix-ci
Fix CI formatting and clippy lints after craft-libs cutover
2026-11-14 00:44:06 -05:00
Dylan M. Taylor d1a36fd0c6 test(cli): update strict error-escaping test for XML 1.0 character filtering 2026-11-14 05:37:27 +00:00
Dylan M. Taylor beae4b82fa Fix formatting and clippy lints in docx and formats adapters 2026-11-14 05:14:05 +00:00
Dylan M. Taylor 514085ca44 Merge pull request #6603 from storytold/codex/craft-libs-cutover
Migrate DOCX and RTF formats to craft-libs (#778)
2026-11-13 22:13:32 -05:00
FredApps 953f43baed Merge branch 'main' into fix/1455-format-painter
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/tests.rs
2026-11-13 20:47:47 +01:00
FredApps acf5eb8727 Merge branch 'main' into fix/artcraft-feature-names-20261108
# Conflicts:
#	crates/engine/src/tests/wordart.rs
2026-11-13 20:47:26 +01:00
Dylan M. Taylor 079f478bd8 Merge tested main for v0.18.1 2026-11-11 18:38:09 -05:00
FredApps 7296cf64de Merge main into release for WordCraft v0.18.0 2026-11-11 19:36:24 +01:00
Dylan M. Taylor 5574b32600 Release 0.17.0 2026-11-09 06:34:22 -05:00
FredApps 6272c9a4da naming: fail display guard when a source root is missing 2026-11-08 22:35:59 +01:00
FredApps 172e56f12e naming: guard production labels against regressions 2026-11-08 22:35:09 +01:00
FredApps b66122f718 naming: use Diagram and Decorative Text labels 2026-11-08 21:39:00 +01:00
FredApps 4e1b6a71ab Merge remote-tracking branch 'up/main' into fix/1455-format-painter
# Conflicts:
#	ROADMAP.md
2026-10-31 18:24:01 +01:00
FredApps b2ec73926f style: format relocated Format Painter assertion 2026-10-30 12:23:48 +01:00
FredApps 7f90fd0bf1 Merge current main and retain Format Painter regression coverage 2026-10-30 12:20:31 +01:00
FredApps 4388fba5b2 Merge tested upstream main into fix/1455-format-painter
Preserve existing PR behavior and reconcile documentation.
Validated against upstream 2f6311cc37 with full local CI.
2026-10-18 04:43:51 +02:00
FredApps c341669dbd Merge main and add browser Copy Formatting alias 2026-10-16 08:42:14 +02:00
FredApps a4d28e2586 Apply Format Painter on single-click release (#1455) 2026-10-16 00:33:35 +02:00
270 changed files with 2945 additions and 52531 deletions
Generated
+356 -118
View File
@@ -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
View File
@@ -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"
+4
View File
@@ -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.
+2 -1
View File
@@ -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 }
+9 -11
View File
@@ -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
View File
@@ -1 +1 @@
1e515422afdda2f317af430c1f9d68a5f83935b4
e56d26244bbac099bfd42e23fb12f78a4be418a7
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+82 -72
View File
@@ -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)
}
+9 -14
View File
@@ -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.
+6 -10
View File
@@ -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.
+3 -5
View File
@@ -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`).
+6 -10
View File
@@ -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"));
+11 -15
View File
@@ -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"];
+8 -13
View File
@@ -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>")
+6 -10
View File
@@ -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> {
+7 -11
View File
@@ -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
+6 -9
View File
@@ -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]
+4 -5
View File
@@ -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() {
+6 -10
View File
@@ -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 {
+6 -10
View File
@@ -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 {
+5 -9
View File
@@ -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);
+37 -51
View File
@@ -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,
];
+8 -11
View File
@@ -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
}
+3 -5
View File
@@ -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 {
+6 -10
View File
@@ -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 {
+5 -8
View File
@@ -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}");
}
}
+3 -4
View File
@@ -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> {
+8 -10
View File
@@ -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 {
+6 -10
View File
@@ -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 {
+8 -11
View File
@@ -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
}
+18 -24
View File
@@ -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()));
}
+6 -10
View File
@@ -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 {
+10 -11
View File
@@ -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
+87 -116
View File
@@ -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 {
+6 -10
View File
@@ -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]
+6 -10
View File
@@ -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]
+12 -17
View File
@@ -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}");
}
+6 -10
View File
@@ -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.
+8 -11
View File
@@ -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
}
+6 -10
View File
@@ -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 {
+6 -10
View File
@@ -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]
+15 -11
View File
@@ -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(),
+3 -5
View File
@@ -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 {
+3 -5
View File
@@ -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 {
+3 -6
View File
@@ -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`.
+5 -1
View File
@@ -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")
+5 -1
View File
@@ -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()
}
+2 -2
View File
@@ -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");
}
+1 -1
View File
@@ -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 }
+58 -2
View File
@@ -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(&registry, 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(&registry, "edit.copyFormat").first().map(String::as_str), Some("Mod+Alt+Shift+C"));
assert!(keys.remove(&registry, "edit.copyFormat", "Mod+Alt+Shift+C").unwrap());
assert!(keys.resolve(&registry, "Mod+Alt+Shift+C").is_none());
keys.assign(&registry, "format.bold", "Mod+Alt+Shift+C").unwrap();
let restored = KeyMap::from_value(&serde_json::to_value(&keys).unwrap());
assert_eq!(restored.resolve(&registry, "Mod+Alt+Shift+C").map(|c| c.id), Some("format.bold"));
assert_eq!(restored.resolve(&registry, "Mod+Shift+C").map(|c| c.id), Some("edit.copyFormat"));
}
}
+6 -10
View File
@@ -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 -6
View File
@@ -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}");
+6 -10
View File
@@ -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": "收件人"}));
+1 -2
View File
@@ -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.
+25 -31
View File
@@ -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);
+9 -12
View File
@@ -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) {
+9 -9
View File
@@ -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()});
+10 -10
View File
@@ -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}`")))?),
+1 -1
View File
@@ -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
View File
@@ -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()
+8 -3
View File
@@ -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)
+3 -4
View File
@@ -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]
+9 -14
View File
@@ -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,
+9
View File
@@ -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 › "));
}
+3 -4
View File
@@ -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`.
+8 -11
View File
@@ -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 {
+8 -14
View File
@@ -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()
+6 -12
View File
@@ -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"]);
}
+11 -9
View File
@@ -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"]);
+5 -11
View File
@@ -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]
+2 -3
View File
@@ -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."]);
+6 -11
View File
@@ -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]
+9 -17
View File
@@ -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 -6
View File
@@ -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]
+10 -8
View File
@@ -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() });
+8 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
-167
View File
@@ -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",
_ => {}
}
}
}
-370
View File
@@ -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()));
}
}
-393
View File
@@ -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("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\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("&nbsp;"),
_ => 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}>&nbsp;</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(&para_html(&Para { kind: Kind::Normal, ..q.clone() }));
out.push('\n');
i += 1;
}
out.push_str("</blockquote>\n");
}
FBlock::Para(p) => {
out.push_str(&para_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
}
-125
View File
@@ -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];
-176
View File
@@ -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 })
}
}
-156
View File
@@ -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
}
-769
View File
@@ -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("a&#65;&#x41;b"), "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&#13;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β&#13;γ</p><a href='https://example.test/a\r\nb\rc&#13;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 &amp; 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 &amp; 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 &lt;b&gt; &#65;&#x42; &bogus; &"), "a <b> AB &bogus; &");
assert_eq!(unescape("&minus; &COPY; &EURO; &TRADE; &frac12; &frac34;"), "− © € ™ ½ ¾");
assert_eq!(unescape("&theta; &lambda; &Delta; &nabla; &sum; &radic; &ne; &le; &ge; &hearts;"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
}
/// 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("&copy 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#169 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#xA9 2024 &amp &lt"), "© 2024 & <");
assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &");
assert_eq!(unescape("&nbsp;x"), "\u{00A0}x");
let f = parse("<p><a href=\"/q?a=1&copy=2&reg2=3&amp;b=&lt\">l</a> &copy 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&copy=2&reg2=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&amp;B<rt>x&lt;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>&nbsp;</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>&nbsp;</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
View File
@@ -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(),
}
}
-489
View File
@@ -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
}
-470
View File
@@ -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}"),
}
}
-11
View File
@@ -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