Compare commits

..
Author SHA1 Message Date
Martin BallandClaude Sonnet 5.5 8042d9d3bb #3518: whole-document OK sends only what was changed in the Language dialog
With "Apply to the whole document" ticked, OK sends noProof only if the user
changed it from the dialog's opening value, and lang if changed or if neither
changed (never the engine's en-US default). Heading reads "Mark the whole
document as:" while ticked. Glossary walker uses doc's PARA_WALK_DEPTH (now pub).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-14 23:37:31 +00:00
Martin BallandClaude Sonnet 5.5 d003be0fd7 #3518: Review › Language can set the whole document's proofing language
review.language takes scope "selection" (default, unchanged) or "document":
every run and paragraph mark of every story (body, headers, footers, notes,
comments, text boxes, table cells, glossary) takes lang/noProof in one undo
step, selection unchanged, pending typing format follows. The Language dialog
gets an "Apply to the whole document" check box.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-14 23:37:31 +00:00
Mex 52d17a91df M14: identify WordCraft correctly in the iframe title example (#6717)
Use word processor as the accessible iframe description instead of image editor.
2026-11-14 21:40:06 +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
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
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
276 changed files with 3170 additions and 52448 deletions
Generated
+338 -100
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"
@@ -7010,16 +7261,17 @@ dependencies = [
name = "wordcraft-cli"
version = "0.18.1"
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]]
@@ -7060,15 +7312,15 @@ dependencies = [
"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]]
@@ -7080,6 +7332,7 @@ dependencies = [
"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"
dependencies = [
"brotli-decompressor",
"brotli-decompressor 5.0.3",
"harfrust 0.13.3",
"kurbo",
"log",
@@ -7123,35 +7375,37 @@ dependencies = [
name = "wordcraft-formats"
version = "0.18.1"
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"
dependencies = [
"craft-image-codecs",
"image",
"serde",
]
@@ -7160,6 +7414,7 @@ dependencies = [
name = "wordcraft-layout"
version = "0.18.1"
dependencies = [
"craft-metafile",
"craft-proof",
"image",
"js-sys",
@@ -7173,7 +7428,6 @@ dependencies = [
"wordcraft-doc",
"wordcraft-fonts",
"wordcraft-geom",
"wordcraft-metafile",
]
[[package]]
@@ -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"
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"
dependencies = [
"craft-metafile",
"image",
"log",
"vello_cpu",
@@ -7228,7 +7475,6 @@ dependencies = [
"wordcraft-fonts",
"wordcraft-geom",
"wordcraft-layout",
"wordcraft-metafile",
]
[[package]]
@@ -7242,6 +7488,7 @@ dependencies = [
"craft-ui",
"craft-update",
"craft-window",
"craft-zip",
"egui",
"egui_extras",
"egui_kittest",
@@ -7261,7 +7508,6 @@ dependencies = [
"wordcraft-render",
"wordcraft-zotero",
"zbus",
"zip 3.0.0",
]
[[package]]
@@ -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",
]
@@ -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"
+20 -8
View File
@@ -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"
+6 -1
View File
File diff suppressed because one or more lines are too long
+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 }
+8 -10
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]
+1 -1
View File
@@ -1 +1 @@
1e515422afdda2f317af430c1f9d68a5f83935b4
e56d26244bbac099bfd42e23fb12f78a4be418a7
+2 -2
View File
@@ -94,8 +94,8 @@ pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
pub(crate) use text_walk::walk_between;
pub use text_walk::{PARA_WALK_DEPTH, count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn count_chars_no_spaces(s: &str) -> usize {
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
pub const PARA_WALK_DEPTH: usize = 16;
/// The note and text box stories an object shows, for `Document::reachable`.
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
+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 }
+6 -27
View File
@@ -19,39 +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);
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)]
+57 -26
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,12 +60,15 @@ 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;
@@ -74,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,
@@ -126,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)?,
})
}
@@ -138,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)
}
@@ -176,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)?;
@@ -192,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)
@@ -344,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()});
+66 -3
View File
@@ -1,4 +1,5 @@
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
use wordcraft_doc::CharProps;
/// Options › Language › Set as default: only the saved default proofing language (what future
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
@@ -14,8 +15,15 @@ pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default and the saved language for future blank documents;
/// `detect` is the per-user detection preference.
/// Nothing at all sets US English, as before.
/// Nothing at all sets US English, as before. `scope: "document"` sets it for every story.
pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
// Checked first, so a bad scope changes nothing (not even the detect preference).
let whole_document = match v.get("scope") {
None | Some(Value::Null) => false,
Some(Value::String(t)) if t == "selection" => false,
Some(Value::String(t)) if t == "document" => true,
Some(other) => return Err(CmdError::Params(format!("`scope` must be \"selection\" or \"document\", not {other}"))),
};
let lang = match p::str(v, "lang") {
Some(t) => Some(
wordcraft_proof::lang::normalize_tag(t)
@@ -45,20 +53,75 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language}));
}
let result = crate::cmd::format::apply(s, &|c| {
let change = |c: &mut CharProps| {
if let Some(l) = &lang {
c.lang = Some(l.clone());
}
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})?;
};
let result = if whole_document {
set_document_props(s, &change)?;
json!({"scope": "document"})
} else {
crate::cmd::format::apply(s, &change)?
};
if p::bool(v, "default") == Some(true) {
s.prefs.default_language = lang;
}
Ok(result)
}
/// Apply `f` to the character formatting of every story of the document (Language dialog, "Apply
/// to the whole document"): text and paragraph marks of the body, headers, footers, notes,
/// comments, text boxes (all `Document::parts`), table cells, and the glossary's building blocks.
/// The selection is untouched; formatting waiting for typing follows.
fn set_document_props(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> Result<(), CmdError> {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for story in stories {
for path in s.doc.para_paths(story) {
let para = s.doc.para_mut(story, &path)?;
let len = para.len();
para.format(0, len, f)?;
f(&mut para.mark);
para.touch();
}
}
for block in &mut s.doc.glossary {
format_blocks(&mut block.blocks, f, 0);
}
if let Some(pend) = s.pending.as_mut() {
f(pend);
}
Ok(())
}
/// [`set_document_props`] for a block list outside any story (depth-limited like `para_paths`).
fn format_blocks(blocks: &mut wordcraft_doc::Blocks, f: &dyn Fn(&mut CharProps), depth: usize) {
if depth > wordcraft_doc::PARA_WALK_DEPTH {
return;
}
for blk in blocks.iter_mut() {
match Arc::make_mut(blk) {
wordcraft_doc::Block::Para(p) => {
let len = p.len();
// A range inside the paragraph can't fail; the mark is formatted regardless.
let _ = p.format(0, len, f);
f(&mut p.mark);
p.touch();
}
wordcraft_doc::Block::Table(t) => {
for row in &mut t.rows {
for cell in &mut row.cells {
format_blocks(&mut cell.blocks, f, depth + 1);
}
}
}
}
}
}
pub(super) fn macros(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("define") {
let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
+1 -1
View File
@@ -340,7 +340,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents), "scope"?: "selection" (default) | "document" (every run and paragraph mark of every story: body, headers, footers, notes, comments, text boxes, tables, glossary; one undo step, selection unchanged)}"#,
),
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
+101
View File
@@ -903,3 +903,104 @@ fn inspect_removals_work_when_the_document_does_not_track_changes() {
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
assert!(text.contains("Visible text."), "{text}");
}
/// #3518: every run and paragraph mark of a story, as (story, path, lang, noProof) rows.
fn proofing_rows(s: &Session) -> Vec<(StoryRef, Option<String>, Option<bool>)> {
let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k)));
let mut out = Vec::new();
for st in stories {
for path in s.doc.para_paths(st) {
let Some(p) = s.doc.para(st, &path) else { continue };
out.push((st, p.mark.lang.clone(), p.mark.no_proof));
out.extend(p.runs.iter().map(|r| (st, r.props.lang.clone(), r.props.no_proof)));
}
}
out
}
/// A document with its text in German in the body, a table cell, a header, a footnote, a comment
/// and a text box, as a Word DOCX keeps a `w:lang` on each run, plus a glossary entry.
fn multi_story_doc() -> Session {
use wordcraft_doc::{Block, BuildingBlock, CharProps, Paragraph, PartKind, Table};
let de = || CharProps { lang: Some("de-DE".into()), ..Default::default() };
let blk = |t: &str| Arc::new(Block::Para(Paragraph::with_text(t, de())));
let mut d = Document::from_text("Guten Tag");
d.body = vec![blk("Guten Tag")];
let mut t = Table::new(1, 2, 200.0);
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
c.blocks = vec![blk("Zelle")];
}
d.body.push(Arc::new(Block::Table(t)));
d.body.push(blk("Ende"));
for kind in [PartKind::Header, PartKind::Footnote, PartKind::Comment, PartKind::TextBox] {
d.add_part(kind, vec![blk("Teil")]);
}
d.glossary.push(BuildingBlock { name: "g".into(), blocks: vec![blk("Baustein")], ..Default::default() });
Session::new(d)
}
#[test]
fn language_scope_document_sets_every_story() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
let (sel, before) = (s.sel.clone(), s.doc.clone());
let rows = proofing_rows(&s);
assert!(rows.len() >= 10 && rows.iter().any(|r| r.1.as_deref() == Some("de-DE")));
s.run("review.language", &json!({"lang": "fr-FR", "scope": "document"})).unwrap();
// Runs that had their own language, the paragraph marks and the empty cell: all French.
let rows = proofing_rows(&s);
assert!(rows.iter().all(|r| r.1.as_deref() == Some("fr-FR")), "{rows:?}");
for kind_story in s.doc.parts.keys() {
assert!(rows.iter().any(|r| r.0 == StoryRef::Part(*kind_story)));
}
assert!(s.doc.plain_text(StoryRef::Body).contains("Zelle"));
let g = &s.doc.glossary[0].blocks;
assert_eq!(g[0].as_para().map(|p| p.runs[0].props.lang.clone()), Some(Some("fr-FR".into())));
assert_eq!(g[0].as_para().map(|p| p.mark.lang.clone()), Some(Some("fr-FR".into())));
// The selection stays; the document is modified.
assert_eq!(s.sel, sel);
assert!(s.doc != before);
// One undo restores everything.
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc == before);
assert_eq!(s.sel, sel);
}
#[test]
fn language_scope_document_no_proof_and_pending() {
let mut s = multi_story_doc();
s.run("review.language", &json!({"noProof": true, "scope": "document"})).unwrap();
assert!(proofing_rows(&s).iter().all(|r| r.2 == Some(true)));
// The language itself wasn't asked for and stays.
assert!(proofing_rows(&s).iter().any(|r| r.1.as_deref() == Some("de-DE")));
// Formatting waiting for typing follows.
s.run("format.bold", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "pl-PL", "scope": "document"})).unwrap();
assert_eq!(s.pending.as_ref().and_then(|p| p.lang.as_deref()), Some("pl-PL"));
}
#[test]
fn language_default_scope_is_selection_only() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR"})).unwrap();
let rows = proofing_rows(&s);
assert!(rows.iter().any(|r| r.1.as_deref() == Some("fr-FR")));
assert!(rows.iter().filter(|r| r.1.as_deref() == Some("fr-FR")).count() < rows.len() / 2);
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR", "scope": "selection"})).unwrap();
assert_eq!(proofing_rows(&s), rows);
}
#[test]
fn language_bad_scope_is_a_params_error() {
let mut s = multi_story_doc();
let before = s.doc.clone();
for bad in [json!("everything"), json!(1), json!(true), json!(["document"])] {
let e = s.run("review.language", &json!({"lang": "fr-FR", "detect": false, "scope": bad})).unwrap_err();
assert!(matches!(e, CmdError::Params(_)), "{e:?}");
}
assert!(s.doc == before);
assert!(s.prefs.detect_language);
}
+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);
}
}

Some files were not shown because too many files have changed in this diff Show More