Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor ae022437c8 doc: reuse shared legacy codepage tables with parity tests 2026-11-15 09:40:33 -05:00
Valentin Klein bc0e51ee4d Interface size: Auto is capped to what fits the screen, Ctrl+Shift+0 returns to Auto (#4030) (#5203)
Fixes #4030

On a small screen (an 11-inch 1366x768 panel) the system can report a very large display scale. The Auto interface size followed it blindly, so the window and ribbon came out far bigger than the screen and File > Options, where the size is set, could not be reached.

What this changes:
- Auto interface size is now capped so a window of at least 900x460 points still fits the screen (about 152% on 1366x768). Scales that already fit (for example 1920x1080 at 100-150%, or 1366x768 at up to 150%) are unchanged. If the screen size is unknown there is no cap.
- A size chosen in File > Options is still taken as is. If a large size pushes the options out of reach, Ctrl+Shift+0 (Cmd+Shift+0 on Mac) returns to Auto from anywhere. Ctrl+Alt+0 is not used because it is AltGr+0 on some layouts and would type a character.
- `ui.interfaceSize` and the "Auto (n%)" label in Options report the capped value. The cap is never saved to `ui.json`.
- Tests: the cap for several screen sizes, the shortcut, AltGr+0 still typing, and the reported percent. Docs: ui-parity, control-protocol, ROADMAP progress log.

Not covered:
- The root cause of the oversized scale (X11 DPI detection) is handled by #721 and #3240. This change is separate and works with or without them.
- A manually chosen large size can still push controls off screen, but there is now a way back.
- Not tested on a real 1366x768 screen.

`cargo xtask ci` passed locally on the pushed code; GitHub CI has not run yet.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Auto interface sizing now scales down on smaller displays to keep a window of at least 900×460 points within the screen. Sizes selected in Options remain uncapped, and the minimum Auto scale is 100%.
  * Press Ctrl+Shift+0 (⌘⇧0 on Mac) to restore Auto sizing. When Auto sizing is capped, the displayed zoom percentage reflects the screen-fit limit; the cap does not change the saved size.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-15 12:12:27 +01:00
batistalucasv ed66198574 .gitattributes: normalise text on add so CRLF cannot be committed again (#6543)
`cargo fmt --check` passes on CRLF (verified: exit 0, empty output), so the gate cannot see a commit that rewrites line endings. `#6537` did exactly that: 50 files, 35,102 lines, all CRLF, while declaring a one-line test change.

## Change

Adds the normalisation the repository lacks — `.gitattributes` only pins `craft-i18n.rev` today:

```
# Text lives in the repository with LF. A Windows checkout once committed 50 files with CRLF
# (#6537) and `cargo fmt --check` still passed, so the gate could not see it. Normalise on add.
* text=auto

craft-i18n.rev text eol=lf
```

`craft-i18n.rev` stays last so the specific rule still wins.

## Dependency

The repair landed first: #6541 merged as `eae8363e`, and `git grep -lI $'\r'` on that head finds **0** text files. This branch merges that `main` in `01617884`, so the rule now applies to a tree that is already LF.

`text=auto` is not an absolute enforcement gate. It normalises text on add, and it does not rewrite a blob already stored with CRLF: plain `git add` on such a file leaves `git ls-files --eol` at `i/mixed`, and only `git add --renormalize` rewrites it to `i/lf`. That is why the repair had to land first, and why this is a guard for future commits, not a fix for the past.

## Why it is safe

- Every text file in `main` is already LF (`git grep -lI $'\r'` = 0).
- No `.bat`/`.cmd` or CRLF fixture exists; the `.ps1` files under `packaging/windows/` are already LF in the repository.
- `cargo fmt --check` clean and `cargo test -p wordcraft-engine --lib` 1460 passed, unchanged.

## Why two PRs

#6541 was the repair; this is the guard, and it is a repository policy change rather than part of the fix, so it is separate and optional. If a repo-wide rule is more than you want, the narrower form is one pattern per line:

```
*.rs text eol=lf
*.toml text eol=lf
*.md text eol=lf
*.properties text eol=lf
```

Thanks for the syntax correction: the single-line fallback I wrote earlier was not valid attribute syntax.

@FredApps — the dependency is settled and this branch is on the new `main`. Ready for approval when you have a moment.
2026-11-15 12:11:56 +01:00
Nine e9750c5cb7 Add Bosnian interface language (#4804)
Register Latin Bosnian as an interface language, with catalog-backed locale, plural and font regressions. Preserve the existing system-locale detector; POSIX script-modifier handling is outside this change.

Original contribution by @nine-codes.
2026-11-15 00:31:44 -06:00
3d5a153e3d Keep TOC entries for tab-numbered headings on one line (#6738)
Fixes #6486

Co-authored-by: cute-johnny <269855522+cute-johnny@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 02:23:52 +01: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
286 changed files with 3882 additions and 52796 deletions
+5 -1
View File
@@ -1 +1,5 @@
craft-i18n.rev text eol=lf
# Text lives in the repository with LF. A Windows checkout once committed 50 files with CRLF
# (#6537) and `cargo fmt --check` still passed, so the gate could not see it. Normalise on add.
* text=auto
craft-i18n.rev text eol=lf
Generated
+339 -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]]
@@ -7030,6 +7282,7 @@ version = "0.18.1"
name = "wordcraft-doc"
version = "0.18.1"
dependencies = [
"craft-codepage",
"base64 0.23.1",
"log",
"proptest",
@@ -7060,15 +7313,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 +7333,7 @@ dependencies = [
"craft-font-catalog",
"craft-font-store",
"craft-proof",
"craft-zip",
"encoding_rs",
"getrandom 0.3.4",
"image",
@@ -7102,14 +7356,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 +7376,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 +7415,7 @@ dependencies = [
name = "wordcraft-layout"
version = "0.18.1"
dependencies = [
"craft-metafile",
"craft-proof",
"image",
"js-sys",
@@ -7173,7 +7429,6 @@ dependencies = [
"wordcraft-doc",
"wordcraft-fonts",
"wordcraft-geom",
"wordcraft-metafile",
]
[[package]]
@@ -7188,19 +7443,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 +7462,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 +7476,6 @@ dependencies = [
"wordcraft-fonts",
"wordcraft-geom",
"wordcraft-layout",
"wordcraft-metafile",
]
[[package]]
@@ -7242,6 +7489,7 @@ dependencies = [
"craft-ui",
"craft-update",
"craft-window",
"craft-zip",
"egui",
"egui_extras",
"egui_kittest",
@@ -7261,7 +7509,6 @@ dependencies = [
"wordcraft-render",
"wordcraft-zotero",
"zbus",
"zip 3.0.0",
]
[[package]]
@@ -7333,7 +7580,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 +7940,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 +7968,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 +7992,15 @@ dependencies = [
"zune-core",
]
[[package]]
name = "zune-jpegxl"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.15.0"
+21 -8
View File
@@ -36,7 +36,8 @@ 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-codepage = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "41fb227542efb8a7748caa1c659f271d8605303c", version = "=0.1.0" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -66,16 +67,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"
+1 -1
View File
@@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) Slovenščina or Bosanski (follows the system language by default) |
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
+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
+1
View File
@@ -16,6 +16,7 @@ workspace = true
test-support = []
[dependencies]
craft-codepage = { workspace = true }
wordcraft-geom = { workspace = true }
base64 = { workspace = true }
log = { workspace = true }
+44 -337
View File
@@ -1,345 +1,52 @@
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
//! 950 Big5), whose bytes decode as runs rather than one at a time.
//! Legacy character decoding delegates to craft-codepage; document fallback policy stays here.
pub use craft_codepage::{
MAC_ROMAN_CODE_PAGE, MULTIBYTE_CODE_PAGES, OEM_CODE_PAGES, SINGLE_BYTE_CODE_PAGES, cp1252, is_multibyte_code_page, multibyte_encoding_name,
oem_byte, windows_byte,
};
/// Windows-1252 code points for 0x80..=0x9F.
const CP1252: [u16; 32] = [
0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178,
];
/// One Windows-1252 byte as a char.
pub fn cp1252(b: u8) -> char {
if (0x80..0xA0).contains(&b) {
CP1252.get((b - 0x80) as usize).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
} else {
b as char
}
/// Word's supported single-byte document code pages (symbol-font policy is separate).
pub fn is_byte_code_page(cp: u16) -> bool {
SINGLE_BYTE_CODE_PAGES.contains(&cp) || OEM_CODE_PAGES.contains(&cp) || cp == MAC_ROMAN_CODE_PAGE
}
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP874: [u16; 128] = [
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
0xFFFD, 0xFFFD,
];
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1250: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
0x0163, 0x02D9,
];
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1251: [u16; 128] = [
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
0x044E, 0x044F,
];
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1253: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
0x03CE, 0xFFFD,
];
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1254: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
0x015F, 0x00FF,
];
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1255: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
0x200F, 0xFFFD,
];
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1256: [u16; 128] = [
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
0x200F, 0x06D2,
];
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1257: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
0x017E, 0x02D9,
];
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1258: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
0x20AB, 0x00FF,
];
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
/// 0xDB (Mac OS 8.5 and later).
const MAC_ROMAN: [u16; 128] = [
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
0x02DB, 0x02C7,
];
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
/// mapping file).
const CP437: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
/// Consortium's CP850 mapping file).
const CP850: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
/// mapping file).
const CP852: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
0x25A0, 0x00A0,
];
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
/// mapping file).
const CP866: [u16; 128] = [
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
0x25A0, 0x00A0,
];
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
/// mapping file).
const CP737: [u16; 128] = [
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
/// mapping file).
const CP857: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
/// Windows' Alt+numpad entry produces them rather than the control characters.
const OEM_GLYPHS: [u16; 31] = [
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
];
/// The OEM code pages [`oem_byte`] decodes.
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
pub fn oem_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
737 => &CP737,
850 => &CP850,
852 => &CP852,
857 => &CP857,
866 => &CP866,
_ => &CP437,
};
let u = match b {
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
0x7F => Some(0x2302),
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
_ => Some(u16::from(b)),
};
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
/// Decode document text without applying OEM control glyphs or symbol-font remapping.
pub fn code_page_byte(cp: u16, b: u8) -> char {
if b >= 0x80 && OEM_CODE_PAGES.contains(&cp) { oem_byte(cp, b) } else { windows_byte(cp, b) }
}
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
/// Mac OS Roman, RTF's `\mac` character set.
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
pub fn windows_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
874 => &CP874,
1250 => &CP1250,
1251 => &CP1251,
1253 => &CP1253,
1254 => &CP1254,
1255 => &CP1255,
1256 => &CP1256,
1257 => &CP1257,
1258 => &CP1258,
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
_ => return cp1252(b),
};
if b < 0x80 {
return b as char;
}
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
/// Whether Word's document import accepts this encoding.
pub fn is_text_code_page(cp: u16) -> bool {
is_byte_code_page(cp) || is_multibyte_code_page(cp)
}
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
pub fn is_byte_code_page(code_page: u16) -> bool {
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
}
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
pub fn code_page_byte(code_page: u16, b: u8) -> char {
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
}
/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
/// (Traditional Chinese).
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
/// its bytes decode as a run, a lead byte together with its trail byte.
pub fn is_multibyte_code_page(code_page: u16) -> bool {
multibyte_encoding_name(code_page).is_some()
}
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
/// decode one at a time, and anything else.
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
Some(match code_page {
932 => "shift_jis",
936 => "gbk",
949 => "euc-kr",
950 => "big5",
_ => return None,
})
}
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
pub fn is_text_code_page(code_page: u16) -> bool {
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
}
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
/// charsets (the document's code page applies) and symbol fonts.
/// Preserve Word's existing symbol-font selection policy, not the shared symbol extension.
pub fn charset_code_page(charset: i32) -> Option<u16> {
Some(match charset {
77 => MAC_ROMAN_CODE_PAGE,
128 => 932,
129 => 949,
134 => 936,
136 => 950,
161 => 1253,
162 => 1254,
163 => 1258,
177 => 1255,
178 => 1256,
186 => 1257,
204 => 1251,
222 => 874,
238 => 1250,
254 | 255 => 437,
_ => return None,
})
if charset == 2 { None } else { craft_codepage::charset_code_page(charset) }
}
#[cfg(test)]
#[allow(dead_code)]
#[path = "encoding_legacy_tests.rs"]
mod legacy;
#[cfg(test)]
mod parity {
use super::*;
#[test]
fn every_supported_byte_and_fallback_matches_original() {
let pages = [0, 42, 437, 737, 850, 852, 857, 866, 874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258, 10000, 9999, u16::MAX];
for cp in pages {
for b in 0..=u8::MAX {
assert_eq!(windows_byte(cp, b), legacy::windows_byte(cp, b), "windows {cp}/{b}");
assert_eq!(oem_byte(cp, b), legacy::oem_byte(cp, b), "oem {cp}/{b}");
assert_eq!(code_page_byte(cp, b), legacy::code_page_byte(cp, b), "text {cp}/{b}");
assert_eq!(cp1252(b), legacy::cp1252(b));
}
}
for cp in 0..=u16::MAX {
assert_eq!(is_byte_code_page(cp), legacy::is_byte_code_page(cp));
assert_eq!(is_text_code_page(cp), legacy::is_text_code_page(cp));
assert_eq!(multibyte_encoding_name(cp), legacy::multibyte_encoding_name(cp));
}
for charset in -1..=256 {
assert_eq!(charset_code_page(charset), legacy::charset_code_page(charset));
}
}
}
#[cfg(test)]
+428
View File
@@ -0,0 +1,428 @@
// Frozen pre-cutover implementation, test-only parity oracle. Original authors retained in file history.
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
//! 950 Big5), whose bytes decode as runs rather than one at a time.
/// Windows-1252 code points for 0x80..=0x9F.
const CP1252: [u16; 32] = [
0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F, 0x0090, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178,
];
/// One Windows-1252 byte as a char.
pub fn cp1252(b: u8) -> char {
if (0x80..0xA0).contains(&b) {
CP1252.get((b - 0x80) as usize).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
} else {
b as char
}
}
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP874: [u16; 128] = [
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
0xFFFD, 0xFFFD,
];
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1250: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
0x0163, 0x02D9,
];
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1251: [u16; 128] = [
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
0x044E, 0x044F,
];
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1253: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
0x03CE, 0xFFFD,
];
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1254: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
0x015F, 0x00FF,
];
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1255: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
0x200F, 0xFFFD,
];
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1256: [u16; 128] = [
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
0x200F, 0x06D2,
];
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1257: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
0x017E, 0x02D9,
];
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
const CP1258: [u16; 128] = [
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
0x20AB, 0x00FF,
];
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
/// 0xDB (Mac OS 8.5 and later).
const MAC_ROMAN: [u16; 128] = [
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
0x02DB, 0x02C7,
];
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
/// mapping file).
const CP437: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
/// Consortium's CP850 mapping file).
const CP850: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
/// mapping file).
const CP852: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
0x25A0, 0x00A0,
];
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
/// mapping file).
const CP866: [u16; 128] = [
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
0x25A0, 0x00A0,
];
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
/// mapping file).
const CP737: [u16; 128] = [
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
/// mapping file).
const CP857: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
/// Windows' Alt+numpad entry produces them rather than the control characters.
const OEM_GLYPHS: [u16; 31] = [
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
];
/// The OEM code pages [`oem_byte`] decodes.
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
pub fn oem_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
737 => &CP737,
850 => &CP850,
852 => &CP852,
857 => &CP857,
866 => &CP866,
_ => &CP437,
};
let u = match b {
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
0x7F => Some(0x2302),
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
_ => Some(u16::from(b)),
};
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
}
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
/// Mac OS Roman, RTF's `\mac` character set.
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
pub fn windows_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
874 => &CP874,
1250 => &CP1250,
1251 => &CP1251,
1253 => &CP1253,
1254 => &CP1254,
1255 => &CP1255,
1256 => &CP1256,
1257 => &CP1257,
1258 => &CP1258,
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
_ => return cp1252(b),
};
if b < 0x80 {
return b as char;
}
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
}
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
pub fn is_byte_code_page(code_page: u16) -> bool {
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
}
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
pub fn code_page_byte(code_page: u16, b: u8) -> char {
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
}
/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
/// (Traditional Chinese).
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
/// its bytes decode as a run, a lead byte together with its trail byte.
pub fn is_multibyte_code_page(code_page: u16) -> bool {
multibyte_encoding_name(code_page).is_some()
}
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
/// decode one at a time, and anything else.
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
Some(match code_page {
932 => "shift_jis",
936 => "gbk",
949 => "euc-kr",
950 => "big5",
_ => return None,
})
}
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
pub fn is_text_code_page(code_page: u16) -> bool {
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
}
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
/// charsets (the document's code page applies) and symbol fonts.
pub fn charset_code_page(charset: i32) -> Option<u16> {
Some(match charset {
77 => MAC_ROMAN_CODE_PAGE,
128 => 932,
129 => 949,
134 => 936,
136 => 950,
161 => 1253,
162 => 1254,
163 => 1258,
177 => 1255,
178 => 1256,
186 => 1257,
204 => 1251,
222 => 874,
238 => 1250,
254 | 255 => 437,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_byte_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
assert_eq!(decode(9999, &[0xE9]), "é");
// Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes.
assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A");
assert_eq!(charset_code_page(77), Some(10000));
assert_eq!(charset_code_page(254), Some(437));
assert_eq!(charset_code_page(255), Some(437));
assert_eq!(charset_code_page(204), Some(1251));
assert_eq!(charset_code_page(0), None);
for cp in SINGLE_BYTE_CODE_PAGES {
for b in 0..=255u8 {
let _ = windows_byte(cp, b);
}
}
}
#[test]
fn windows_1255_holam_haser_for_vav() {
assert_eq!(windows_byte(1255, 0xCA), '\u{05BA}');
}
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
#[test]
fn document_text_in_windows_dos_and_mac_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII");
assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ");
assert_eq!(decode(10000, &[0x8E]), "é");
assert_eq!(decode(1251, &[0xC4]), "Д");
for cp in [437, 850, 866, 1250, 1252, 10000] {
assert!(is_byte_code_page(cp), "{cp}");
}
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
}
/// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode
/// as runs (a lead byte and its trail byte together), not one at a time.
#[test]
fn east_asian_code_pages_are_multibyte() {
assert_eq!(charset_code_page(128), Some(932), "Japanese");
assert_eq!(charset_code_page(129), Some(949), "Korean");
assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese");
assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese");
assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte");
assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]);
for cp in MULTIBYTE_CODE_PAGES {
assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}");
assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}");
assert!(multibyte_encoding_name(cp).is_some(), "{cp}");
}
assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000));
assert!(!is_text_code_page(0) && !is_text_code_page(9999));
}
#[test]
fn oem_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂");
assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ");
assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę");
assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№");
assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω");
assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş");
assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857");
assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437");
for cp in OEM_CODE_PAGES {
for b in 0..=255u8 {
let _ = oem_byte(cp, b);
}
}
}
}
+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) {
+25 -8
View File
@@ -67,13 +67,15 @@ pub fn toc_instr(v: &Value) -> String {
s
}
/// A TOC entry: the paragraph it points at, its level (0-based), its text, and the leading page
/// breaks of that paragraph (its page is where the text after them lands).
/// A TOC entry: the paragraph it points at, its level (0-based), its text, the leading page
/// breaks of that paragraph (its page is where the text after them lands), and, for a number
/// followed by a tab, how far the heading's text starts after where its number starts.
struct TocEntry {
path: Path,
level: u8,
text: String,
lead: usize,
number_gap: Option<f32>,
}
/// The headings of the body, in document order, as (body block, level 1–9, text, leading page
@@ -117,9 +119,13 @@ fn toc_entries(s: &Session, f: &FieldCode) -> Vec<TocEntry> {
.and_then(|n| counters.next_label(&s.doc.numbering, n.num, n.level))
.filter(|(label, def)| !label.is_empty() && !matches!(def.format, NumFormat::Bullet | NumFormat::None))
.map(|(label, def)| match def.suffix {
LevelSuffix::Tab => format!("{label}\t"),
LevelSuffix::Space => format!("{label} "),
LevelSuffix::Nothing => label,
LevelSuffix::Tab => {
let start = def.indent - def.hanging;
let gap = def.tab.filter(|t| *t > start).unwrap_or(def.indent) - start;
(format!("{label}\t"), Some(gap).filter(|g| g.is_finite() && *g > 0.0))
}
LevelSuffix::Space => (format!("{label} "), None),
LevelSuffix::Nothing => (label, None),
});
let style = p.props.style.as_deref().unwrap_or("Normal");
if style.starts_with("TOC") {
@@ -144,8 +150,9 @@ fn toc_entries(s: &Session, f: &FieldCode) -> Vec<TocEntry> {
if let Some(l) = by_style.or(by_outline).or(if hidden { None } else { by_heading }) {
let t = p.plain_text().replace(['\u{000C}', '\n', '\t'], " ").trim().to_string();
if !t.is_empty() {
let text = number.map_or_else(|| t.clone(), |n| format!("{n}{t}"));
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead });
let text = number.as_ref().map_or_else(|| t.clone(), |(n, _)| format!("{n}{t}"));
let number_gap = number.as_ref().and_then(|(_, g)| *g);
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead, number_gap });
}
}
if let Some(id) = &tc {
@@ -159,7 +166,7 @@ fn toc_entries(s: &Session, f: &FieldCode) -> Vec<TocEntry> {
let l = c.arg('l').and_then(|l| l.trim().parse::<u8>().ok()).unwrap_or(1).clamp(1, 9);
let text = c.args.first().map(|t| t.trim().to_string()).unwrap_or_default();
if (id.is_empty() || *id == kind) && (tc_levels.0..=tc_levels.1).contains(&l) && !text.is_empty() {
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead: off });
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead: off, number_gap: None });
}
}
}
@@ -595,6 +602,16 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
para.mark = CharProps::default();
if e.text.contains('\t') {
para.props.tabs = tabs.iter().find(|(style, _)| Some(style) == para.props.style.as_ref()).map(|(_, tabs)| tabs.clone());
// Without a stop for the number (LibreOffice's Contents styles have only the page
// number's right stop), its tab would run to the page number: the entry gets a
// left stop as far after its indent as the heading's text is after its number.
let style = s.doc.styles.resolve_para(&para.props);
let has_stop =
style.tabs.iter().any(|t| t.pos < width - 1.0 && matches!(t.align, TabAlign::Left | TabAlign::Center | TabAlign::Decimal));
if let (None, false, Some(gap)) = (&para.props.tabs, has_stop, e.number_gap) {
let pos = style.indent_left.max(0.0) + gap;
para.props.tabs = (pos < width - 1.0).then(|| vec![TabStop { pos, align: TabAlign::Left, leader: TabLeader::None }]);
}
}
if show_page && sep.contains('\t') {
let right = TabStop { pos: width - 0.5, align: TabAlign::Right, leader };
+9 -9
View File
@@ -28,7 +28,7 @@ const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGB
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
CommandSpec::new("insert.smartArt", "Diagram", "Insert › Illustrations", insert).params(
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
),
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
let below = p::bool(v, "below").unwrap_or(false);
edit(s, move |g| {
if g.items.len() >= MAX_ITEMS {
return Err(CmdError::Failed(format!("a SmartArt graphic holds at most {MAX_ITEMS} items")));
return Err(CmdError::Failed(format!("a diagram holds at most {MAX_ITEMS} items")));
}
let Some(last) = g.items.len().checked_sub(1) else {
g.items.push(SmartArtItem::new(text.clone(), 0));
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"layout": "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn"}"#)
.when(has_smart_art),
CommandSpec::new("smartArt.colors", "Change Colors", "SmartArt Design › SmartArt Styles", |s, v| {
CommandSpec::new("smartArt.colors", "Change Colors", "SmartArt Design › Diagram Styles", |s, v| {
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
edit(s, move |g| {
g.colors = colors;
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › Diagram Styles", |s, v| {
let i = index(v, "index")?;
let color = match v.get("color") {
None | Some(Value::Null) => None,
@@ -174,9 +174,9 @@ fn has_smart_art(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.smart_art.is_some() => None,
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.kind == GraphicKind::Diagram => {
Some("this SmartArt was made in another program: WordCraft shows it but can't edit it")
Some("this diagram was made in another program: WordCraft shows it but can't edit it")
}
_ => Some("select a SmartArt graphic first"),
_ => Some("select a diagram first"),
}
}
@@ -299,9 +299,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
/// Change the selected graphic's model with `f`, then lay it out again at its size.
fn edit(s: &mut Session, f: impl Fn(&mut SmartArtSpec) -> Result<(), CmdError>) -> CmdResult {
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a SmartArt graphic first".into()))?;
let InlineObject::Graphic { w, h, graphic, .. } = obj else { return Err(CmdError::Disabled("select a SmartArt graphic first".into())) };
let mut spec = graphic.smart_art.as_deref().cloned().ok_or_else(|| CmdError::Disabled("this SmartArt graphic can't be edited".into()))?;
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a diagram first".into()))?;
let InlineObject::Graphic { w, h, graphic, .. } = obj else { return Err(CmdError::Disabled("select a diagram first".into())) };
let mut spec = graphic.smart_art.as_deref().cloned().ok_or_else(|| CmdError::Disabled("this diagram can't be edited".into()))?;
f(&mut spec)?;
let next = Arc::new(smart_art_graphic(spec, &s.doc.settings.theme_colors, w, h));
let out = json!({"smartArt": next.smart_art.as_deref()});
+10 -10
View File
@@ -19,24 +19,24 @@ const EFFECT_PARAMS: &str = r#"{"style"?: "solid"|"outlined"|"hollow"|"gradient"
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", insert).params(
r#"{"text"?: string, "style"?: WordArt style id (see format.textEffects), "transform"?: "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|… (a DrawingML preset text warp)}"#,
CommandSpec::new("insert.wordArt", "Decorative Text", "Insert › Text", insert).params(
r#"{"text"?: string, "style"?: Decorative Text style id (see format.textEffects), "transform"?: "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|… (a DrawingML preset text warp)}"#,
),
CommandSpec::new("format.textEffects", "Text Effects", "Home › Font", text_effects).params(EFFECT_PARAMS),
CommandSpec::new("wordArt.style", "WordArt Styles", "Shape Format › WordArt Styles", |s, v| {
CommandSpec::new("wordArt.style", "Decorative Text Styles", "Shape Format › Decorative Text Styles", |s, v| {
let id = p::req_str(v, "style")?;
text_effects(s, &json!({"style": id}))
})
.params(r#"{"style": "solid"|"outlined"|"hollow"|"gradient"|"shadowed"|"glowing"|"reflected"|"sunset"}"#),
CommandSpec::new("wordArt.textFill", "Text Fill", "Shape Format › WordArt Styles", |s, v| {
CommandSpec::new("wordArt.textFill", "Text Fill", "Shape Format › Decorative Text Styles", |s, v| {
text_effects(s, &json!({"fill": v.get("fill").or_else(|| v.get("color")).cloned().unwrap_or(Value::Null)}))
})
.params(r#"{"fill": "RRGGBB"|"none"|{"gradient": ["RRGGBB", …], "angle"?: deg}|null}"#),
CommandSpec::new("wordArt.textOutline", "Text Outline", "Shape Format › WordArt Styles", |s, v| {
CommandSpec::new("wordArt.textOutline", "Text Outline", "Shape Format › Decorative Text Styles", |s, v| {
text_effects(s, &json!({"outline": v.get("outline").cloned().unwrap_or_else(|| v.clone())}))
})
.params(r#"{"color"?: "RRGGBB"|null, "width"?: pt} or {"outline": "none"|null}"#),
CommandSpec::new("wordArt.transform", "Transform", "Shape Format › WordArt Styles", transform)
CommandSpec::new("wordArt.transform", "Transform", "Shape Format › Decorative Text Styles", transform)
.params(r#"{"preset": "textArchUp"|"textArchDown"|"textCircle"|"textWave1"|"textSlantUp"|"textSlantDown"|"textInflate"|"textDeflate"|"textChevron"|"textChevronInverted"|"textTriangle"|"textTriangleInverted"|… (any DrawingML preset text warp)|"none", "adj"?: n (the preset's adjust value)}"#),
]
}
@@ -186,7 +186,7 @@ fn text_effects(s: &mut Session, v: &Value) -> CmdResult {
let theme = theme(s);
let accent = theme.get(4).copied().unwrap_or(Rgb(0x15, 0x60, 0x82));
let style = match p::str(v, "style") {
Some(id) => Some(art_style(id, &theme).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{id}`")))?),
Some(id) => Some(art_style(id, &theme).ok_or_else(|| CmdError::Params(format!("unknown decorative text style `{id}`")))?),
None => None,
};
let clear = p::bool(v, "clear").unwrap_or(false);
@@ -244,11 +244,11 @@ fn transform(s: &mut Session, v: &Value) -> CmdResult {
Some(w)
}
};
let pos = warp_target(s).ok_or_else(|| CmdError::Disabled("select a shape or WordArt first".into()))?;
let pos = warp_target(s).ok_or_else(|| CmdError::Disabled("select a shape or decorative text first".into()))?;
let para = s.doc.para_mut(pos.story, &pos.path)?;
match para.object_at_mut(pos.off) {
Some(InlineObject::Shape { extra, .. }) => extra.warp = warp.clone(),
_ => return Err(CmdError::Disabled("select a shape or WordArt first".into())),
_ => return Err(CmdError::Disabled("select a shape or decorative text first".into())),
}
para.touch();
Ok(json!({"transform": warp.map(|w| w.preset)}))
@@ -269,7 +269,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text: String = p::str(v, "text").unwrap_or("Your text here").chars().filter(|c| !c.is_control()).take(1000).collect();
let text = if text.trim().is_empty() { "Your text here".to_string() } else { text };
let style = p::str(v, "style").unwrap_or("solid");
let fx = art_style(style, &theme(s)).ok_or_else(|| CmdError::Params(format!("unknown WordArt style `{style}`")))?;
let fx = art_style(style, &theme(s)).ok_or_else(|| CmdError::Params(format!("unknown decorative text style `{style}`")))?;
let warp = match p::str(v, "transform") {
None | Some("none") => None,
Some(id) => Some(TextWarp::new(id).ok_or_else(|| CmdError::Params(format!("unknown text warp `{id}`")))?),
+1 -1
View File
@@ -468,7 +468,7 @@ pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Resu
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let bytes = save_bytes_opts(name, doc, opts)?;
match password {
Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw),
Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw).map_err(|e| e.to_string()),
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
_ => Ok(bytes),
}
+109 -130
View File
@@ -4271,7 +4271,6 @@ fn toc_menu_offers_update_field_after_save_and_reopen() {
/// doesn't grow the document.
#[test]
fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_save() {
use std::io::{Read, Write};
let style = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
@@ -4289,7 +4288,7 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
style("Heading1", &t("Chapter One")),
style("Heading2", &t("Section"))
);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zip = craft_zip::Writer::new();
for (name, value) in [
("word/document.xml", xml.as_str()),
(
@@ -4297,16 +4296,21 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
zip.add(name, value.as_bytes(), craft_zip::Compression::Deflated).unwrap();
}
let mut s = Session::new(wordcraft_docx::read(&zip.finish().unwrap().into_inner()).unwrap());
let mut s = Session::new(wordcraft_docx::read(&zip.finish().unwrap()).unwrap());
let mut blocks = None;
for round in 0..3 {
run(&mut s, "references.updateToc", json!({}));
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let mut xml = String::new();
zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
let xml = String::from_utf8(
craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 })
.unwrap()
.read("word/document.xml", 16 << 20)
.unwrap()
.unwrap(),
)
.unwrap();
assert_eq!(xml.matches("<w:sdt>").count(), 1, "round {round}: {xml}");
let (open, close) = (xml.find("<w:sdt>").unwrap(), xml.find("</w:sdt>").unwrap());
assert!(open < xml.find(">Contents<").unwrap(), "round {round}: the heading is in the control: {xml}");
@@ -4328,11 +4332,10 @@ fn style_xml(id: &str, name: &str, ppr: &str, rpr: &str) -> String {
/// A DOCX package with `body` and the paragraph `styles` after a default Normal style.
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
use std::io::Write;
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
const RELS: &str = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
const TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zip = craft_zip::Writer::new();
for (name, value) in [
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
(
@@ -4350,10 +4353,9 @@ fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
format!(r#"<Relationships {RELS}><Relationship Id="main" Type="{TYPE}/officeDocument" Target="word/document.xml"/></Relationships>"#),
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
zip.add(name, value.as_bytes(), craft_zip::Compression::Deflated).unwrap();
}
zip.finish().unwrap().into_inner()
zip.finish().unwrap()
}
/// #3891: Update Table on a TOC Word wrote in Portuguese keeps the formatting of its TOC styles,
@@ -4671,12 +4673,8 @@ fn update_toc_clears_stale_field_result_for_docx_span() {
// DOCX round-trip: entries must be enclosed within the TOC field rather than outside it.
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let xml = {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).unwrap();
let mut file = archive.by_name("word/document.xml").unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut file, &mut s).unwrap();
s
let mut archive = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
String::from_utf8(archive.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
};
let b = xml.find(r#"w:fldCharType="begin""#).expect("TOC begin marker present");
// The TOC's end is the last one: the entries' page numbers are `PAGEREF` fields inside it.
@@ -4900,9 +4898,8 @@ fn toc_page_numbers_are_pageref_fields() {
let expected = text(&s);
assert!(expected.contains("Alpha\t2") && expected.contains("Bravo\t3"), "{expected}");
let bytes = crate::io::save_bytes("toc.docx", &s.doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
assert_eq!(xml.matches("PAGEREF _Toc").count(), 2, "{xml}");
let mut s = Session::new(crate::io::open_bytes("toc.docx", &bytes).unwrap());
run(&mut s, "references.updateFields", json!({}));
@@ -5549,17 +5546,15 @@ fn an_odt_frame_moved_from_its_page_loses_the_page_number() {
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><draw:frame text:anchor-type="page" text:anchor-page-number="3" svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p><text:p>Two</text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let odt = z.finish().unwrap().into_inner();
let odt = z.finish().unwrap();
let frame_tag = |s: &Session| {
let mut a = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
let mut c = String::new();
std::io::Read::read_to_string(&mut a.by_name("content.xml").unwrap(), &mut c).unwrap();
let _a_bytes = wordcraft_formats::odt::export(&s.doc).unwrap();
let mut a = craft_zip::Archive::open(&_a_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let c = String::from_utf8(a.read("content.xml", 16 << 20).unwrap().unwrap()).unwrap();
c.split("<draw:frame").nth(1).and_then(|t| t.split('>').next()).unwrap_or_default().to_string()
};
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
@@ -6033,10 +6028,11 @@ fn html_pictures_load_relative_to_the_file() {
// Issue #98: `<img src="logo.png">` beside an HTML file is embedded when it is opened.
let dir = std::env::temp_dir().join(format!("wordcraft-html-img-{}", std::process::id()));
std::fs::create_dir_all(dir.join("img")).unwrap();
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 2, image::Rgba([10, 20, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let png = wordcraft_geom::picture::encode_format(
&image::DynamicImage::from(image::RgbaImage::from_pixel(4, 2, image::Rgba([10, 20, 30, 255])).clone()),
image::ImageFormat::Png,
)
.unwrap();
std::fs::write(dir.join("img/my logo.png"), &png).unwrap();
std::fs::write(dir.join("secret.txt"), b"not a picture").unwrap();
let html = r#"<p><img src="img/my%20logo.png" alt="Logo"></p><p><img src="secret.txt" alt="T"></p><p><img src="http://example.com/x.png" alt="Web"></p>"#;
@@ -6060,11 +6056,11 @@ fn scratch_dir(name: &str) -> std::path::PathBuf {
#[cfg(not(target_arch = "wasm32"))]
fn tiny_png() -> Vec<u8> {
let mut png = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
wordcraft_geom::picture::encode_format(
&image::DynamicImage::from(image::RgbaImage::from_pixel(2, 2, image::Rgba([200, 30, 30, 255])).clone()),
image::ImageFormat::Png,
)
.unwrap()
}
#[cfg(not(target_arch = "wasm32"))]
@@ -8276,7 +8272,7 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
assert_eq!((r["width"].as_f64(), r["height"].as_f64(), r["icon"].as_str()), (Some(48.0), Some(48.0), Some("heart")));
let Some((_, InlineObject::Image { media, w, h, alt, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
assert_eq!((w, h, alt.as_str()), (48.0, 48.0, "Heart"));
let png = image::load_from_memory(&s.doc.media[&media]).unwrap().to_rgba8();
let png = wordcraft_geom::picture::decode_memory(&s.doc.media[&media]).unwrap().to_rgba8();
assert_eq!(png.dimensions(), (200, 200), "about 300 pixels per inch");
let inked: Vec<_> = png.pixels().filter(|p| p.0[3] == 255).collect();
assert!(!inked.is_empty() && inked.iter().all(|p| p.0[..3] == [0xC0, 0, 0]));
@@ -8288,7 +8284,7 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
// Icon Color repaints it, keeping the transparency.
run(&mut s, "picture.iconColor", json!({"color": "1F4E79"}));
let Some((_, InlineObject::Image { media: m2, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") };
let png2 = image::load_from_memory(&s.doc.media[&m2]).unwrap().to_rgba8();
let png2 = wordcraft_geom::picture::decode_memory(&s.doc.media[&m2]).unwrap().to_rgba8();
assert!(png2.pixels().zip(png.pixels()).all(|(a, b)| a.0[3] == b.0[3] && (a.0[3] == 0 || a.0[..3] == [0x1F, 0x4E, 0x79])));
assert!(s.run("picture.iconColor", &json!({})).is_err());
}
@@ -9839,7 +9835,6 @@ fn english_dialects_survive_a_docx_round_trip() {
if no_language_data("english_dialects_survive_a_docx_round_trip") {
return;
}
use std::io::Read;
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Colour and color. Centre and center."}));
run(&mut s, "select.all", json!({}));
@@ -9849,12 +9844,8 @@ fn english_dialects_survive_a_docx_round_trip() {
let before = spelling_with_first_suggestion(&mut s);
assert_eq!(before, pairs(&[("color", "colour"), ("Centre", "Center")]));
let bytes = crate::io::save_bytes("british.docx", &s.doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut part = |name: &str| {
let mut xml = String::new();
zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
xml
};
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let mut part = |name: &str| String::from_utf8(zip.read(name, 16 << 20).unwrap().unwrap()).unwrap();
assert!(part("word/styles.xml").contains(r#"w:val="en-GB""#), "the default language is written");
assert!(part("word/document.xml").contains(r#"w:val="en-US""#), "the run's language is written");
let doc = crate::io::open_bytes("british.docx", &bytes).unwrap();
@@ -10335,7 +10326,6 @@ fn select_graphic(s: &mut Session) {
/// reopens as the same editable graphic; an edited data part stays read-only.
#[test]
fn smart_art_round_trips_through_docx() {
use std::io::Read;
let mut s = s();
let r = run(
&mut s,
@@ -10345,12 +10335,11 @@ fn smart_art_round_trips_through_docx() {
assert_eq!(r["smartArt"]["layout"], "hierarchy");
let spec = cmd::smart_art::selected_smart_art(&s).expect("selected after insert");
let bytes = crate::io::save_bytes("sa.docx", &s.doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
for part in ["data1", "layout1", "quickStyle1", "colors1", "drawing1"] {
assert!(zip.by_name(&format!("word/diagrams/{part}.xml")).is_ok(), "{part} written");
assert!(zip.contains(&format!("word/diagrams/{part}.xml")), "{part} written");
}
let mut ct = String::new();
zip.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap();
let ct = String::from_utf8(zip.read("[Content_Types].xml", 16 << 20).unwrap().unwrap()).unwrap();
assert!(ct.contains("diagramDrawing+xml") && ct.contains("diagramData+xml"), "{ct}");
let mut s2 = Session::new(crate::io::open_bytes("sa.docx", &bytes).unwrap());
select_graphic(&mut s2);
@@ -10362,20 +10351,17 @@ fn smart_art_round_trips_through_docx() {
select_graphic(&mut s3);
assert_eq!(cmd::smart_art::selected_smart_art(&s3).map(|g| g.layout), Some(wordcraft_doc::smart_art::SmartArtLayout::Venn));
// A data part another program rewrote: shown, not editable.
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&again)).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
let name = f.name().to_string();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
let mut zip = craft_zip::Archive::open(&again, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let names: Vec<String> = zip.names().map(|n| n.into_owned()).collect();
let mut out = craft_zip::Writer::new();
for name in names {
let mut buf = zip.read(&name, 16 << 20).unwrap().unwrap();
if name.ends_with("data1.xml") {
buf = String::from_utf8(buf).unwrap().replace("<dgm:bg/>", "<dgm:bg><a:noFill/></dgm:bg>").into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut out, &buf).unwrap();
out.add(&name, &buf, craft_zip::Compression::Deflated).unwrap();
}
let foreign = out.finish().unwrap().into_inner();
let foreign = out.finish().unwrap();
let mut s4 = Session::new(crate::io::open_bytes("sa.docx", &foreign).unwrap());
select_graphic(&mut s4);
assert!(cmd::smart_art::selected_smart_art(&s4).is_none());
@@ -10641,10 +10627,11 @@ fn outline_many_collapsed_headings_stay_fast_after_edits() {
#[test]
fn imported_pictures_fit_the_text_width() {
let png = |w: u32, h: u32| {
let mut b = Vec::new();
image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
.unwrap();
let b = wordcraft_geom::picture::encode_format(
&image::DynamicImage::from(image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])).clone()),
image::ImageFormat::Png,
)
.unwrap();
cmd::insert::base64_encode(&b)
};
let sizes = |doc: &wordcraft_doc::Document| -> Vec<(f32, f32)> {
@@ -11245,8 +11232,11 @@ fn picture_change_keeps_the_replacement_format() {
let dir = scratch_dir("picture-change-format");
let first = dir.join("first.png");
std::fs::write(&first, tiny_png()).unwrap();
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let jpeg = wordcraft_geom::picture::encode_format(
&image::DynamicImage::from(image::RgbImage::from_pixel(2, 2, image::Rgb([30, 30, 200])).clone()),
image::ImageFormat::Jpeg,
)
.unwrap();
let second = dir.join("second.jpg");
std::fs::write(&second, &jpeg).unwrap();
@@ -11271,10 +11261,16 @@ fn picture_change_keeps_the_replacement_format() {
#[test]
fn picture_change_keeps_width_and_takes_new_aspect() {
let dir = scratch_dir("picture-change-aspect");
let mut wide = Vec::new();
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
let mut tall = Vec::new();
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
let wide = wordcraft_geom::picture::encode_format(
&image::DynamicImage::from(image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).clone()),
image::ImageFormat::Png,
)
.unwrap();
let tall = wordcraft_geom::picture::encode_format(
&image::DynamicImage::from(image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).clone()),
image::ImageFormat::Png,
)
.unwrap();
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
std::fs::write(&first, &wide).unwrap();
std::fs::write(&second, &tall).unwrap();
@@ -11407,7 +11403,6 @@ fn links_to_places_in_this_document() {
/// formula field's result is edited (#1037). The first field's result is split across the field
/// end; the second has its whole result deleted.
fn tracked_field_result_docx() -> Vec<u8> {
use std::io::Write;
const WHO: &str = r#"w:author="Tester" w:date="2026-10-12T00:00:00Z""#;
let field = |instr: &str, inside: &str, after: &str| {
format!(
@@ -11421,7 +11416,7 @@ fn tracked_field_result_docx() -> Vec<u8> {
let xml = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p>{p1}</w:p><w:p>{p2}</w:p></w:body></w:document>"#
);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zip = craft_zip::Writer::new();
for (name, value) in [
("word/document.xml", xml.as_str()),
(
@@ -11429,10 +11424,9 @@ fn tracked_field_result_docx() -> Vec<u8> {
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="main" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#,
),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(value.as_bytes()).unwrap();
zip.add(name, value.as_bytes(), craft_zip::Compression::Deflated).unwrap();
}
zip.finish().unwrap().into_inner()
zip.finish().unwrap()
}
#[test]
@@ -11446,9 +11440,8 @@ fn tracked_deletion_inside_a_field_result_is_a_revision_and_is_saved() {
assert_eq!(p.text.replace(wordcraft_doc::para::OBJ, ""), "$150.00$140.00");
// Saving keeps the deletion inside the result, with its text as delText.
let bytes = wordcraft_docx::write(&doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
let mut z = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let xml = String::from_utf8(z.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
let sep = xml.find(r#"w:fldCharType="separate""#).unwrap();
let end = xml.find(r#"w:fldCharType="end""#).unwrap();
let inside = &xml[sep..end];
@@ -12053,13 +12046,11 @@ fn opened_documents_with_their_own_page_size_keep_it() {
fn html_converted_to_docx_keeps_bold_headers_and_right_aligned_cells() {
// #1381: `wordcraft-cli convert S05.html out.docx`: the <th> runs are bold and the
// `text-align:right` cells' paragraphs carry `w:jc w:val="right"`.
use std::io::Read;
let mut s = s();
run(&mut s, "file.open", json!({"path": "S05.html", "data": crate::cmd::insert::base64_encode(SAMPLE_1381.as_bytes())}));
let bytes = crate::io::save_bytes("out.docx", &s.doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
let mut zip = craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let xml = String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap();
let cell = |text: &str| {
let at = xml.find(&format!(">{text}<")).unwrap_or_else(|| panic!("{text} in {xml}"));
let start = xml[..at].rfind("<w:tc>").or_else(|| xml[..at].rfind("<w:tc ")).unwrap();
@@ -12204,7 +12195,6 @@ fn typing_after_a_note_number_is_not_superscript() {
/// an ODT save, so Reject All (undo the deletion, then the insertion) and Accept All both remove it.
#[test]
fn odt_inserted_then_deleted_text_round_trips() {
use std::io::Write;
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
let info = |who: &str| format!("<office:change-info><dc:creator>{who}</dc:creator><dc:date>2024-03-01T10:00:00</dc:date></office:change-info>");
let content = format!(
@@ -12212,13 +12202,11 @@ fn odt_inserted_then_deleted_text_round_trips() {
info("Alice"),
info("Bob")
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
let doc = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
for cmd in ["review.rejectAll", "review.acceptAll"] {
let mut s = Session::new(doc.clone());
@@ -12231,7 +12219,6 @@ fn odt_inserted_then_deleted_text_round_trips() {
/// Reject All turns back into the original and Accept All into the edited text.
#[test]
fn odt_tracked_changes_accept_and_reject_all() {
use std::io::Write;
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
let info = |who: &str| format!("<office:change-info><dc:creator>{who}</dc:creator><dc:date>2024-03-01T10:00:00</dc:date></office:change-info>");
let content = format!(
@@ -12239,13 +12226,11 @@ fn odt_tracked_changes_accept_and_reject_all() {
info("Alice"),
info("Bob")
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let bytes = z.finish().unwrap().into_inner();
let bytes = z.finish().unwrap();
let doc = crate::io::open_bytes("t.odt", &bytes).unwrap();
let mut sr = Session::new(doc.clone());
@@ -12263,7 +12248,6 @@ fn odt_tracked_changes_accept_and_reject_all() {
/// inserted, so Reject All keeps the two paragraphs (after an ODT save, too).
#[test]
fn odt_deleted_mark_inside_an_insertion_survives_reject_all() {
use std::io::Write;
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/""#;
let info = |who: &str| format!("<office:change-info><dc:creator>{who}</dc:creator><dc:date>2024-03-01T10:00:00</dc:date></office:change-info>");
let content = format!(
@@ -12271,13 +12255,11 @@ fn odt_deleted_mark_inside_an_insertion_survives_reject_all() {
info("Alice"),
info("Bob")
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
let saved = crate::io::open_bytes("t.odt", &wordcraft_formats::odt::export(&doc).unwrap()).unwrap();
for d in [doc, saved] {
let mut s = Session::new(d);
@@ -13091,9 +13073,9 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
run(&mut s, "insert.chart", json!({"type": "column"}));
let first = wordcraft_formats::odt::export(&s.doc).unwrap();
let doc = crate::io::open_bytes("t.odt", &first).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
let mut content = String::new();
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap();
let _z_bytes = wordcraft_formats::odt::export(&doc).unwrap();
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let content = String::from_utf8(z.read("content.xml", 16 << 20).unwrap().unwrap()).unwrap();
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
}
@@ -13109,18 +13091,16 @@ fn an_odts_picture_wider_than_the_text_keeps_its_size() {
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame draw:name="Rule" text:anchor-type="paragraph" svg:x="-1cm" svg:width="18cm" svg:height="0.2cm"><draw:image xlink:href="Pictures/r.png"/></draw:frame>Text</text:p></office:text></office:body></office:document-content>"#
);
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/r.png", &png),
] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data).unwrap();
z.add(name, data, craft_zip::Compression::Stored).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
let width = doc.body[0]
.as_para()
.unwrap()
@@ -13129,10 +13109,10 @@ fn an_odts_picture_wider_than_the_text_keeps_its_size() {
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None });
let want = 18.0 * 72.0 / 2.54;
assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
let mut saved = String::new();
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap();
assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}");
let _z_bytes = wordcraft_formats::odt::export(&doc).unwrap();
let mut z = craft_zip::Archive::open(&_z_bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let saved = String::from_utf8(z.read("content.xml", 16 << 20).unwrap().unwrap()).unwrap();
assert!(saved.contains(r#"svg:width="510.24pt""#), "written as read: {saved}");
}
/// Numbering or a new style makes the styles be written from the model on save: a document in
@@ -13146,14 +13126,12 @@ fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("styles.xml", styles.as_str())]
{
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let odt = z.finish().unwrap().into_inner();
let odt = z.finish().unwrap();
for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] {
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English");
@@ -13167,7 +13145,6 @@ fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
#[cfg(not(target_arch = "wasm32"))]
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
use std::io::Write;
let files: [(String, Vec<u8>); 4] = [
(
"[Content_Types].xml".into(),
@@ -13183,12 +13160,11 @@ fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> V
format!(r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body}</w:body></w:document>"#).into_bytes(),
),
];
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zw = craft_zip::Writer::new();
for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
zw.add(name, bytes, craft_zip::Compression::Deflated).unwrap();
}
zw.finish().unwrap().into_inner()
zw.finish().unwrap()
}
/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853.
@@ -13417,24 +13393,27 @@ fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let mut png = std::io::Cursor::new(Vec::new());
image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap();
std::io::Write::write_all(
&mut png,
&wordcraft_geom::picture::encode_format(&image::DynamicImage::from(image::RgbImage::new(1000, 500).clone()), image::ImageFormat::Png)
.unwrap(),
)
.unwrap();
let png = png.into_inner();
let open = |attrs: &str| {
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/a.png", &png),
] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data).unwrap();
z.add(name, data, craft_zip::Compression::Stored).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap()).unwrap();
doc.body[0]
.as_para()
.unwrap()
+8 -3
View File
@@ -273,10 +273,15 @@ fn typing_kurdish_in_new_document_advances_caret_smoothly() {
/// The runs of the first paragraph in a saved DOCX: (has `w:rtl`, text).
fn saved_runs(s: &Session) -> Vec<(bool, String)> {
use std::io::Read;
let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let mut xml = String::new();
zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
let xml = String::from_utf8(
craft_zip::Archive::open(&bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 })
.unwrap()
.read("word/document.xml", 16 << 20)
.unwrap()
.unwrap(),
)
.unwrap();
let para = xml.split("<w:body>").nth(1).unwrap().split("</w:p>").next().unwrap().to_string();
para.split("<w:r>")
.skip(1)
+3 -4
View File
@@ -149,12 +149,11 @@ fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec<
}
table.push_str("</w:font></w:fonts>");
files.push(("word/fontTable.xml".into(), table.into_bytes()));
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zip = craft_zip::Writer::new();
for (name, bytes) in files {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, &bytes).unwrap();
zip.add(&name, &bytes, craft_zip::Compression::Deflated).unwrap();
}
zip.finish().unwrap().into_inner()
zip.finish().unwrap()
}
#[test]
+9 -14
View File
@@ -200,7 +200,6 @@ fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() {
/// up a level and then ends the list, as with direct numbering.
#[test]
fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
use std::io::Write;
let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
let style = |id: &str, name: &str, ilvl: u8| {
format!(
@@ -227,8 +226,7 @@ fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
let doc_rels =
format!("<Relationships {rels_ns}>{}{}</Relationships>", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml"));
let ct = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/></Types>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
let mut z = craft_zip::Writer::new();
for (name, data) in [
("[Content_Types].xml", ct),
("_rels/.rels", main.as_str()),
@@ -237,10 +235,9 @@ fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
("word/styles.xml", styles.as_str()),
("word/numbering.xml", numbering.as_str()),
] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap();
let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap()).unwrap();
let mut s = Session::new(doc);
// The list level each paragraph shows, wherever its numbering comes from.
let levels = |s: &Session| -> Vec<Option<u8>> {
@@ -2433,12 +2430,11 @@ fn two_section_docx(kind: &str) -> Vec<u8> {
),
),
];
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zip = craft_zip::Writer::new();
for (name, data) in files {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
zip.add(name, data.as_bytes(), craft_zip::Compression::Deflated).unwrap();
}
zip.finish().unwrap().into_inner()
zip.finish().unwrap()
}
/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as
@@ -2504,12 +2500,11 @@ fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec<u8> {
),
),
];
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zip = craft_zip::Writer::new();
for (name, data) in files {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
zip.add(name, data.as_bytes(), craft_zip::Compression::Deflated).unwrap();
}
zip.finish().unwrap().into_inner()
zip.finish().unwrap()
}
/// #2127: Delete at the end of the last paragraph removes a section break on its mark too,
+9
View File
@@ -200,3 +200,12 @@ fn wordart_reflection_and_outline_none_options() {
assert_eq!(outline.color, None);
assert_eq!(outline.width, 1.5);
}
#[test]
fn descriptive_feature_labels_keep_existing_command_ids() {
let s = Session::new(Document::new());
for (id, label) in [("insert.smartArt", "Diagram"), ("insert.wordArt", "Decorative Text"), ("wordArt.style", "Decorative Text Styles")] {
assert_eq!(s.registry.get(id).map(|spec| spec.label), Some(label));
}
assert!(s.registry.get("smartArt.colors").unwrap().location.starts_with("SmartArt Design › "));
}
+3 -4
View File
@@ -7,12 +7,11 @@ use std::io::Write;
use wordcraft_doc::{InlineObject, StoryRef};
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut zw = craft_zip::Writer::new();
for (name, body) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
zw.add(name, body.as_bytes(), craft_zip::Compression::Deflated).unwrap();
}
zw.finish().unwrap().into_inner()
zw.finish().unwrap()
}
/// A field run: its code, its cached result, and whether it carries `w:fldLock`.
+8 -11
View File
@@ -2,7 +2,6 @@
//! macro-enabled document keeps its VBA project (as opaque bytes) when it is saved again.
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
use wordcraft_doc::Document;
use wordcraft_engine::io::{open_bytes, save_bytes};
@@ -18,26 +17,24 @@ const REL_VBA_DATA: &str = "http://schemas.microsoft.com/office/2006/relationshi
const VBA_DATA: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><wne:vbaSuppData xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wne:mcds><wne:mcd wne:macroName="PROJECT.NEWMACROS.HELLO" wne:name="Project.NewMacros.Hello" wne:bEncrypt="00" wne:cmg="56"/></wne:mcds></wne:vbaSuppData>"#;
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut z = craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).expect("zip");
let names: Vec<String> = z.names().map(|n| n.into_owned()).collect();
let mut out = BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let mut b = Vec::new();
f.read_to_end(&mut b).expect("read entry");
out.insert(f.name().to_string(), b);
for name in names {
let b = z.read(&name, 16 << 20).expect("read entry").expect("entry");
out.insert(name, b);
}
out
}
fn zip(entries: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut zw = craft_zip::Writer::new();
// Content types first, as Word writes them.
let order = entries.iter().filter(|(n, _)| *n == "[Content_Types].xml").chain(entries.iter().filter(|(n, _)| *n != "[Content_Types].xml"));
for (name, bytes) in order {
zw.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).expect("start");
zw.write_all(bytes).expect("write");
zw.add(name.as_str(), bytes, craft_zip::Compression::Deflated).expect("write");
}
zw.finish().expect("finish").into_inner()
zw.finish().expect("finish")
}
fn text(files: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
+8 -14
View File
@@ -2,8 +2,6 @@
//! WordCraft draws and edits: Chart Design's commands change it like a chart made here. The ODT
//! is original and synthetic, shaped like LibreOffice's.
use std::io::Write;
use serde_json::json;
use wordcraft_doc::chart::ChartType;
use wordcraft_doc::para::InlineObject;
@@ -17,14 +15,12 @@ fn odt() -> Vec<u8> {
);
// A well-formed chart part, its prefixes declared as LibreOffice writes them.
let chart = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"><office:body><office:chart><chart:chart chart:class="chart:line"><chart:title><text:p>Visits</text:p></chart:title><chart:plot-area><chart:axis chart:dimension="x"><chart:categories table:cell-range-address="local-table.$A$2:.$A$4"/></chart:axis><chart:series chart:values-cell-range-address="local-table.$B$2:.$B$4" chart:label-cell-address="local-table.$B$1"/></chart:plot-area><table:table table:name="local-table"><table:table-header-rows><table:table-row><table:table-cell/><table:table-cell office:value-type="string"><text:p>Site</text:p></table:table-cell></table:table-row></table:table-header-rows><table:table-rows><table:table-row><table:table-cell office:value-type="string"><text:p>Mon</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Tue</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="5"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Wed</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="4"/></table:table-row></table:table-rows></table:table></chart:chart></office:chart></office:body></office:document-content>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("Object 1/content.xml", chart)]
{
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
z.finish().unwrap().into_inner()
z.finish().unwrap()
}
/// The document's charts: their type, title, and whether they're drawn.
@@ -55,20 +51,18 @@ fn an_odt_chart_opens_drawn_and_editable() {
fn a_chart_in_a_group_opens_drawn() {
// The chart is a member of a drawing group (`draw:g`): it is drawn too.
let odt = odt();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&odt)).unwrap();
let chart = std::io::read_to_string(z.by_name("Object 1/content.xml").unwrap()).unwrap();
let mut z = craft_zip::Archive::open(&odt, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let chart = String::from_utf8(z.read("Object 1/content.xml", 16 << 20).unwrap().unwrap()).unwrap();
let content = format!(
r#"<office:document-content {NS}><office:body><office:text><text:p>Group <draw:g text:anchor-type="as-char"><draw:frame svg:x="0in" svg:y="0in" svg:width="3in" svg:height="2in"><draw:object xlink:href="./Object 1"/></draw:frame><draw:rect svg:x="3in" svg:y="0in" svg:width="1in" svg:height="1in"/></draw:g></text:p></office:text></office:body></office:document-content>"#
);
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut w = craft_zip::Writer::new();
for (name, data) in
[("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("Object 1/content.xml", chart.as_str())]
{
w.start_file(name, o).unwrap();
w.write_all(data.as_bytes()).unwrap();
w.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let doc = io::open_bytes("group.odt", &w.finish().unwrap().into_inner()).unwrap();
let doc = io::open_bytes("group.odt", &w.finish().unwrap()).unwrap();
let members: Vec<bool> = doc
.body
.iter()
+6 -12
View File
@@ -2,8 +2,6 @@
//! the bookmarked text (a bookmark over text, `text:bookmark-start`…`-end`, not a point). The
//! ODT is original and synthetic, shaped like LibreOffice's.
use std::io::Write;
use serde_json::json;
use wordcraft_engine::{Session, io};
@@ -11,13 +9,11 @@ const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1
fn odt(body: &str) -> Vec<u8> {
let content = format!(r#"<office:document-content {NS}><office:body><office:text>{body}</office:text></office:body></office:document-content>"#);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
z.finish().unwrap().into_inner()
z.finish().unwrap()
}
fn text(s: &Session) -> Vec<String> {
@@ -87,13 +83,11 @@ fn a_flat_document_opens_and_saves_by_its_extension() {
fn a_user_field_updates_from_its_property() {
// An ODT user field opens as `DOCPROPERTY`: updating fields shows the property's value.
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:user-field-decls><text:user-field-decl text:name="Owner" office:value-type="string" office:string-value="Ada"/></text:user-field-decls><text:p>By <text:user-field-get text:name="Owner">stale</text:user-field-get>.</text:p></office:text></office:body></office:document-content>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let mut s = Session::new(io::open_bytes("user.odt", &z.finish().unwrap().into_inner()).unwrap());
let mut s = Session::new(io::open_bytes("user.odt", &z.finish().unwrap()).unwrap());
s.run("references.updateFields", &json!({"all": true})).unwrap();
assert_eq!(results(&s), ["Ada"]);
}
+11 -9
View File
@@ -2,21 +2,17 @@
//! stay what they are when the document is saved as DOCX (`w:sdt`) and as ODT again. The ODT is
//! original and synthetic, shaped like LibreOffice's.
use std::io::{Read, Write};
use wordcraft_doc::control::ControlKind;
use wordcraft_doc::para::InlineObject;
use wordcraft_engine::io;
fn odt() -> Vec<u8> {
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"><office:body><office:text><office:forms><form:form form:name="Form"><form:listbox form:name="Colour" form:id="c1" form:dropdown="true"><form:option form:label="Red"/><form:option form:label="Green" form:current-selected="true"/></form:listbox><form:checkbox form:name="Agree" form:id="c2" form:current-state="unchecked"/></form:form></office:forms><text:p>Colour: <draw:control draw:control="c1"/> Agree: <draw:control draw:control="c2"/></text:p></office:text></office:body></office:document-content>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
z.finish().unwrap().into_inner()
z.finish().unwrap()
}
fn kinds(doc: &wordcraft_doc::Document) -> Vec<&'static str> {
@@ -36,8 +32,14 @@ fn odt_form_controls_become_content_controls_everywhere() {
assert_eq!(text, "Colour: Green Agree: \u{2610}");
// DOCX: Word content controls.
let docx = io::save_bytes("form.docx", &doc).unwrap();
let mut body = String::new();
zip::ZipArchive::new(std::io::Cursor::new(&docx)).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut body).unwrap();
let body = String::from_utf8(
craft_zip::Archive::open(&docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 })
.unwrap()
.read("word/document.xml", 16 << 20)
.unwrap()
.unwrap(),
)
.unwrap();
assert!(body.contains("<w:dropDownList") && body.contains("w14:checkbox"), "{body}");
let again = io::open_bytes("form.docx", &docx).unwrap();
assert_eq!(kinds(&again), ["dropDown", "checkBox"]);
+5 -11
View File
@@ -2,8 +2,6 @@
//! the object behind: saved as ODT it is the spreadsheet again; saved as DOCX it is the picture
//! alone, never ODF markup. The ODT is original and synthetic, shaped like LibreOffice's.
use std::io::{Read, Write};
use wordcraft_doc::para::InlineObject;
use wordcraft_engine::io;
@@ -14,8 +12,7 @@ fn odt() -> Vec<u8> {
r#"<office:document-content {NS}><office:body><office:text><text:p>Costs <draw:frame svg:width="3in" svg:height="1in"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="./ObjectReplacements/Object 1"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let sheet = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"><office:body><office:spreadsheet/></office:body></office:document-content>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
let entries: [(&str, &[u8]); 4] = [
("mimetype", b"application/vnd.oasis.opendocument.text"),
("content.xml", content.as_bytes()),
@@ -23,17 +20,14 @@ fn odt() -> Vec<u8> {
("ObjectReplacements/Object 1", b"VCLMTF not a picture WordCraft draws"),
];
for (name, data) in entries {
z.start_file(name, o).unwrap();
z.write_all(data).unwrap();
z.add(name, data, craft_zip::Compression::Deflated).unwrap();
}
z.finish().unwrap().into_inner()
z.finish().unwrap()
}
fn entry(pkg: &[u8], name: &str) -> Option<String> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap();
let mut s = String::new();
z.by_name(name).ok()?.read_to_string(&mut s).ok()?;
Some(s)
let mut z = craft_zip::Archive::open(pkg, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
String::from_utf8(z.read(name, 16 << 20).ok()??).ok()
}
#[test]
+2 -3
View File
@@ -22,9 +22,8 @@ fn an_odt_is_saved_and_opened_with_a_password() {
fn an_odt_template_opens_and_saves_as_a_template_with_a_password_too() {
let doc = io::open_bytes("plain.txt", b"Letterhead.").unwrap();
let ott = io::save_bytes("letter.ott", &doc).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&ott)).unwrap();
let mut mime = String::new();
std::io::Read::read_to_string(&mut z.by_name("mimetype").unwrap(), &mut mime).unwrap();
let mut z = craft_zip::Archive::open(&ott, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
let mime = String::from_utf8(z.read("mimetype", 16 << 20).unwrap().unwrap()).unwrap();
assert_eq!(mime, wordcraft_formats::odt::TEMPLATE_MIME);
let opened = io::open_bytes("letter.ott", &ott).unwrap();
assert_eq!(opened.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect::<Vec<_>>(), ["Letterhead."]);
+6 -11
View File
@@ -3,8 +3,6 @@
//! whose automatic styles name a master page (`style:master-page-name`), one restarting page
//! numbering (`style:page-number`), and a two-column `text:section`.
use std::io::{Read, Write};
use wordcraft_engine::io;
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
@@ -33,20 +31,17 @@ fn odt() -> Vec<u8> {
<text:p text:style-name="PBack">Closing words</text:p><text:p>Last line.</text:p>
</office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", &content), ("styles.xml", &styles)] {
z.start_file(name, o).unwrap();
z.write_all(data.as_bytes()).unwrap();
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
z.finish().unwrap().into_inner()
z.finish().unwrap()
}
fn document_xml(docx: &[u8]) -> String {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut xml = String::new();
zip.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
xml
let mut zip = craft_zip::Archive::open(docx, craft_zip::Limits { max_entries: 10_000, max_total_bytes: 64 << 20 }).unwrap();
String::from_utf8(zip.read("word/document.xml", 16 << 20).unwrap().unwrap()).unwrap()
}
#[test]
+64 -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,15 +93,68 @@ 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:?}");
assert!(!text.iter().any(|t| t.contains("stale")), "{text:?}");
}
/// #6486: LibreOffice numbers headings through its outline style, the number followed by a tab,
/// and its Contents styles carry only the right-aligned dotted stop for the page number. After
/// Update Table, the tab after an entry's number stops near the number, not at the page-number
/// stop: the title stays at the left and each entry stays on one line.
#[test]
fn an_odt_toc_with_numbered_headings_keeps_its_entries_on_one_line() {
let outline = |level: u8, cm: f32| {
format!(
r#"<text:outline-level-style text:level="{level}" style:num-format="1" style:num-suffix="." text:display-levels="{level}"><style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="{cm}cm" fo:text-indent="-{cm}cm" fo:margin-left="{cm}cm"/></style:list-level-properties></text:outline-level-style>"#
)
};
let contents = |n: u8, indent: f32| {
format!(
r#"<style:style style:name="Contents_20_{n}" style:display-name="Contents {n}" style:family="paragraph"><style:paragraph-properties fo:margin-left="{indent}cm"><style:tab-stops><style:tab-stop style:position="16.99cm" style:type="right" style:leader-style="dotted" style:leader-text="."/></style:tab-stops></style:paragraph-properties></style:style>"#
)
};
let styles = format!(
r#"<office:document-styles {NS}><office:styles>{}{}<text:outline-style style:name="Outline">{}{}</text:outline-style></office:styles><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21.001cm" fo:page-height="29.7cm" 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="pm1"/></office:master-styles></office:document-styles>"#,
contents(1, 0.0),
contents(2, 0.499),
outline(1, 0.762),
outline(2, 1.016)
);
let content = format!(
r#"<office:document-content {NS}><office:body><office:text>
<text:table-of-content text:name="T"><text:table-of-content-source text:outline-level="2"/><text:index-body><text:p text:style-name="Contents_20_1">1. Planning<text:tab/>1</text:p><text:p text:style-name="Contents_20_2">1.1. Budget<text:tab/>1</text:p></text:index-body></text:table-of-content>
<text:h text:outline-level="1">Planning</text:h><text:p>Text.</text:p>
<text:h text:outline-level="2">Budget</text:h><text:p>More text.</text:p>
</office:text></office:body></office:document-content>"#
);
let mut z = craft_zip::Writer::new();
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("styles.xml", styles.as_str()), ("content.xml", content.as_str())]
{
z.add(name, data.as_bytes(), craft_zip::Compression::Stored).unwrap();
}
let mut s = Session::new(io::open_bytes("toc.odt", &z.finish().unwrap()).unwrap());
s.run("references.updateToc", &json!({})).unwrap();
let layout = s.layout();
let mut checked = 0;
for (i, b) in s.doc.body.iter().enumerate() {
let Some(p) = b.as_para().filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading")) else {
continue;
};
let Some(tab) = p.text.find('\t') else { continue };
let pos = |off| wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(i), off };
let title = layout.caret(&pos(tab + 1)).unwrap();
let end = layout.caret(&pos(p.len())).unwrap();
assert!(p.plain_text().contains("Planning") || p.plain_text().contains("Budget"), "{:?}", p.text);
assert_eq!((title.page, title.top), (end.page, end.top), "{:?} wraps", p.text);
assert!(title.x < 200.0, "{:?}: the title starts at {}", p.text, title.x);
checked += 1;
}
assert_eq!(checked, 2);
}
@@ -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
}

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