mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 21:37:41 -06:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac2fa7bbb7 | ||
|
|
087d330a24 | ||
|
|
5b5810fe1f | ||
|
|
3b7f7ed10b | ||
|
|
5d063c5bc2 | ||
|
|
1cbb027962 | ||
|
|
a57dd81dba | ||
|
|
a4b1682b41 | ||
|
|
2f78615291 | ||
|
|
9632646c5e | ||
|
|
18ae8b2150 | ||
|
|
9a172e2241 | ||
|
|
db2b3cab99 | ||
|
|
665ded2800 | ||
|
|
d40e0eeb47 | ||
|
|
2e790d549c | ||
|
|
74b2cbb5c9 | ||
|
|
d39be3bab8 | ||
|
|
f2debdecff | ||
|
|
48f5a905b1 | ||
|
|
4abb77aa70 | ||
|
|
e855daf279 | ||
|
|
f774f6f5c0 | ||
|
|
5b952b2463 | ||
|
|
81833cfc33 | ||
|
|
48545479b0 | ||
|
|
990b57a1e8 | ||
|
|
0debb7afda | ||
|
|
bc437049fa | ||
|
|
6e8a420d28 | ||
|
|
64ebfda971 | ||
|
|
c99552c958 | ||
|
|
250a9c5164 | ||
|
|
1be606de03 | ||
|
|
685b420104 | ||
|
|
4fcc409c8b | ||
|
|
bd85c72f39 | ||
|
|
8a964609c4 | ||
|
|
4a12ab9a19 | ||
|
|
41d5284fa8 | ||
|
|
931324e049 | ||
|
|
983f1e0267 | ||
|
|
39ae5f6778 | ||
|
|
5714408dd8 | ||
|
|
964a7bc5a8 | ||
|
|
3508cac3b0 | ||
|
|
8148a239e6 | ||
|
|
acccf913ab | ||
|
|
dc48046057 | ||
|
|
22e99fc2b7 | ||
|
|
f658c872a2 | ||
|
|
a066fe5f13 | ||
|
|
ccb8ce8494 | ||
|
|
165d5725dc | ||
|
|
01a1bf3921 | ||
|
|
5a9e414d1e | ||
|
|
aadb077e07 | ||
|
|
9724f15c3d | ||
|
|
3de6ff95af | ||
|
|
65bb8b3709 | ||
|
|
c4fafc2154 | ||
|
|
df5a59275d | ||
|
|
ab91e9f96c | ||
|
|
1c8f6e9257 | ||
|
|
bc05149bd9 | ||
|
|
51c70e1606 | ||
|
|
ac2cc0c4ef | ||
|
|
9c0f61356c | ||
|
|
260a629e9e | ||
|
|
1ecb22c67d | ||
|
|
36f6244cd4 | ||
|
|
ffaeda9316 | ||
|
|
815d3f0032 | ||
|
|
937c7e532d | ||
|
|
7976368de7 | ||
|
|
0436139c57 | ||
|
|
a2435388e5 | ||
|
|
75d30d69b4 | ||
|
|
1d3e6d9ca8 | ||
|
|
d522bfd8b1 | ||
|
|
0bd3420553 | ||
|
|
54e1b1e7f3 | ||
|
|
4b8b06fe03 | ||
|
|
de3bfaf049 | ||
|
|
f8e327b068 | ||
|
|
50a8ebe307 | ||
|
|
f270dbd3b0 | ||
|
|
ffa35d62db | ||
|
|
436d444ef0 | ||
|
|
ffa127c615 | ||
|
|
8cdc172171 | ||
|
|
d603983a61 | ||
|
|
72b8818ea6 | ||
|
|
3031870ce3 | ||
|
|
1b0a59d4d2 | ||
|
|
8edd6cb8eb | ||
|
|
89a059fb2d | ||
|
|
1b485e1f90 | ||
|
|
751eb4b204 | ||
|
|
5225569d44 | ||
|
|
f2fe7654e9 | ||
|
|
7757828295 | ||
|
|
04ebc4ed42 | ||
|
|
cd683a9fd1 | ||
|
|
932a2e8955 |
@@ -21,3 +21,27 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -19,6 +19,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -31,3 +31,5 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-dir: WordCraft
|
||||
install-relative-path: Programs/ArtCraft/WordCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -82,3 +82,8 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
@@ -13,7 +13,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
|
||||
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`).
|
||||
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
|
||||
|
||||
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
|
||||
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
|
||||
@@ -63,3 +63,8 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
|
||||
## Roadmap
|
||||
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+34
-32
@@ -809,9 +809,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
version = "5.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
@@ -1014,12 +1014,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1042,7 +1042,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1051,7 +1051,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1064,7 +1064,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1084,13 +1084,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1109,7 +1110,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1207,9 +1208,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -2854,7 +2855,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6647,7 +6648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6682,7 +6683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6695,11 +6696,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6713,7 +6714,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6730,7 +6731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6754,7 +6755,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6785,7 +6786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6799,7 +6800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -6812,12 +6813,13 @@ dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6825,7 +6827,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -6845,7 +6847,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6857,7 +6859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6865,7 +6867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -6886,7 +6888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -6896,7 +6898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6910,7 +6912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6941,7 +6943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6960,7 +6962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7123,7 +7125,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+8
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -58,8 +58,9 @@ wuff = "0.2.9"
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
@@ -96,9 +97,11 @@ arboard = { version = "3.7", default-features = false, features = ["wayland-data
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
too_many_arguments = "deny"
|
||||
type_complexity = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces; full debug info
|
||||
@@ -120,7 +123,7 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -221,16 +221,17 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer (all users) | Installer (just me) | Portable |
|
||||
|---|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
|
||||
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
|
||||
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
|
||||
in the Start menu. Installers and executables are code-signed.
|
||||
The installer is one dual-scope package. By default it installs for your account only,
|
||||
into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing
|
||||
"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build)
|
||||
and asks for an administrator. Install only one copy: a copy for another scope is refused, so
|
||||
uninstall first to change who WordCraft is installed for. Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
|
||||
+58
-2
File diff suppressed because one or more lines are too long
+26
@@ -0,0 +1,26 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security problems privately through GitHub:
|
||||
**[Report a vulnerability](https://github.com/storytold/wordcraft/security/advisories/new)**
|
||||
(Security tab › Advisories › Report a vulnerability).
|
||||
|
||||
Don't open a public issue or pull request for a vulnerability until a fix is released.
|
||||
|
||||
Useful to include:
|
||||
- the WordCraft version or commit, and the platform (macOS, Windows, Linux, BSD, web);
|
||||
- what an attacker can do, and what they need (a crafted document, MCP or control-channel access, a local account…);
|
||||
- steps or a sample file that reproduce it.
|
||||
|
||||
We'll acknowledge the report, keep you updated while we work on a fix, and credit you in the advisory unless you'd rather not be named.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes go into `main` and ship in the next release. Older releases don't get backported fixes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: anything that lets a document, file, command, MCP or control-channel request, or network response read or write files it shouldn't, run code, leak data, or crash WordCraft and lose the user's work.
|
||||
|
||||
Crashes from hostile input count, because WordCraft treats every panic as a bug (see [Never crash](AGENTS.md)).
|
||||
@@ -391,6 +391,27 @@ fn valid_connect_addr(addr: &str) -> bool {
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
|
||||
match arg_value(rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => {
|
||||
let source = script_path.unwrap_or(Path::new(&path));
|
||||
Ok(Some(script::load(source)?))
|
||||
}
|
||||
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_source_paths(rest: &[String], script: Option<&script::Script>) -> (Option<String>, Option<String>) {
|
||||
// A source named on the command line wins over both of the script's.
|
||||
let (mut from_file, mut from_template) = (arg_value(rest, "--file"), arg_value(rest, "--template"));
|
||||
if from_file.is_none() && from_template.is_none() {
|
||||
from_file = script.and_then(|loaded| loaded.file.clone());
|
||||
from_template = script.and_then(|loaded| loaded.template.clone());
|
||||
}
|
||||
(from_file, from_template)
|
||||
}
|
||||
|
||||
/// Run the subcommand named by `args` (the command line after the program name). Files named
|
||||
/// there are opened by their real names, which may not be valid Unicode.
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
@@ -474,17 +495,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let page = parse_page_arg("run", &rest)?;
|
||||
let scale = parse_scale_arg("run", &rest)?;
|
||||
let password = password_from(&rest)?;
|
||||
let script = match arg_value(&rest, "--script") {
|
||||
Some(path) if !path.starts_with("--") => Some(script::load(script_path.as_deref().unwrap_or(Path::new(&path)))?),
|
||||
_ if rest.iter().any(|a| a == "--script") => return Err("run: --script requires a value".into()),
|
||||
_ => None,
|
||||
};
|
||||
// A source named on the command line wins over both of the script's.
|
||||
let (mut from_file, mut from_template) = (arg_value(&rest, "--file"), arg_value(&rest, "--template"));
|
||||
if from_file.is_none() && from_template.is_none() {
|
||||
from_file = script.as_ref().and_then(|c| c.file.clone());
|
||||
from_template = script.as_ref().and_then(|c| c.template.clone());
|
||||
}
|
||||
let script = load_run_script(&rest, script_path.as_deref())?;
|
||||
let (from_file, from_template) = run_source_paths(&rest, script.as_ref());
|
||||
let mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
|
||||
|
||||
@@ -49,9 +49,9 @@ impl PrefsSync {
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author == *host
|
||||
&& prefs.author.as_deref() == Some(host.as_str())
|
||||
{
|
||||
prefs.author = own.clone();
|
||||
prefs.author = Some(own.clone());
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
Ok(j) => j,
|
||||
|
||||
@@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
|
||||
@@ -47,10 +47,9 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
log::warn!("{p}: {e}");
|
||||
let e = e.to_string();
|
||||
let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}");
|
||||
app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)]));
|
||||
app.report_open_failure(p, &e);
|
||||
// Backstage hides the status bar, so leave it to show the error.
|
||||
app.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,26 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
(args, files)
|
||||
}
|
||||
|
||||
/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and
|
||||
/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with
|
||||
/// the value after each. They are not files, and opening them as files fails.
|
||||
fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
let mut kept = Vec::with_capacity(args.len());
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a.starts_with("-psn_") {
|
||||
continue;
|
||||
}
|
||||
if a.starts_with("-NS") || a.starts_with("-Apple") {
|
||||
// Its value, unless the next argument is another flag.
|
||||
args.next_if(|v| !v.starts_with('-'));
|
||||
continue;
|
||||
}
|
||||
kept.push(a);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
@@ -340,7 +360,9 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("{name}: {e}"),
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +372,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
@@ -360,6 +382,8 @@ fn main() -> eframe::Result {
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let (args, other_files) = text_args();
|
||||
// Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name.
|
||||
let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args };
|
||||
let mut bad_control: Option<Option<String>> = None;
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
@@ -504,7 +528,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
log::warn!("{f}: {e}");
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
@@ -590,6 +614,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_arg_tests {
|
||||
use super::without_launch_args;
|
||||
|
||||
fn strip(args: &[&str]) -> Vec<String> {
|
||||
without_launch_args(args.iter().map(|a| a.to_string()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launch_arguments_are_not_files() {
|
||||
assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]);
|
||||
// A flag with no value after it, or followed by another flag, takes nothing with it.
|
||||
assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_arguments_are_kept() {
|
||||
assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]);
|
||||
// Files whose names merely start like a launch argument keep their place when they are paths.
|
||||
assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]);
|
||||
assert!(strip(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_tests {
|
||||
use super::write_prefs;
|
||||
|
||||
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -181,3 +181,12 @@ sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
|
||||
[people.TobyWu666]
|
||||
display_name = "tobywu"
|
||||
|
||||
[people.Bra1nZ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, Deserialize, Paragraph, Serialize, Table};
|
||||
|
||||
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
|
||||
@@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
|
||||
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
|
||||
/// first is 0 for non-empty text.
|
||||
pub fn cluster_starts(text: &str) -> Vec<usize> {
|
||||
// ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29).
|
||||
if text.is_ascii() {
|
||||
let b = text.as_bytes();
|
||||
return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect();
|
||||
}
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
let mut prev: Option<char> = None;
|
||||
for (i, g) in text.grapheme_indices(true) {
|
||||
@@ -110,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
|
||||
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
|
||||
/// South and South-East Asia.
|
||||
/// Characters Backspace removes individually rather than with their whole cluster: Arabic
|
||||
/// combining marks and characters of the Brahmic scripts of South and South-East Asia.
|
||||
pub fn backspaces_by_char(c: char) -> bool {
|
||||
matches!(u32::from(c),
|
||||
0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0)
|
||||
| 0x064B..=0x065F
|
||||
| 0x0670
|
||||
| 0x06D6..=0x06DC
|
||||
| 0x06DF..=0x06E4
|
||||
| 0x06E7..=0x06E8
|
||||
| 0x06EA..=0x06ED
|
||||
| 0x0897..=0x089F
|
||||
| 0x08CA..=0x08E1
|
||||
| 0x08E3..=0x08FF
|
||||
| 0x10EFA..=0x10EFF
|
||||
| 0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
| 0x0E00..=0x0EFF // Thai, Lao
|
||||
| 0x0F00..=0x0FFF // Tibetan
|
||||
| 0x1000..=0x109F // Myanmar
|
||||
@@ -188,6 +203,22 @@ mod tests {
|
||||
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
|
||||
}
|
||||
|
||||
/// The ASCII shortcut gives the grapheme rules' answer.
|
||||
#[test]
|
||||
fn ascii_cluster_starts_match_the_graphemes() {
|
||||
let graphemes = |t: &str| -> Vec<usize> { t.grapheme_indices(true).map(|(i, _)| i).collect() };
|
||||
let all: String = (0u8..128).map(char::from).collect();
|
||||
for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] {
|
||||
assert_eq!(cluster_starts(t), graphemes(t), "{t:?}");
|
||||
}
|
||||
for a in 0u8..128 {
|
||||
for b in 0u8..128 {
|
||||
let t = format!("{}{}", char::from(a), char::from(b));
|
||||
assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
|
||||
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
|
||||
@@ -229,8 +260,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_by_character_in_brahmic_scripts_only() {
|
||||
fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() {
|
||||
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
|
||||
assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}'));
|
||||
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
|
||||
assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::Part;
|
||||
use super::*;
|
||||
use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table};
|
||||
|
||||
pub fn para_block(p: Paragraph) -> Arc<Block> {
|
||||
Arc::new(Block::Para(p))
|
||||
|
||||
+26
-13
@@ -14,7 +14,7 @@
|
||||
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
|
||||
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -422,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
}
|
||||
|
||||
/// Positions of the markers without a partner in a block list and the cells inside it.
|
||||
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let events = control_events(bl);
|
||||
let (_, lone) = pair_events(&events);
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: count those before it on the same table.
|
||||
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
|
||||
tables.push((Path(path), nth, true));
|
||||
if !lone.is_empty() {
|
||||
let mut table_counts: HashMap<usize, usize> = HashMap::new();
|
||||
let mut open_nth = Vec::with_capacity(events.len());
|
||||
for e in &events {
|
||||
if let ControlEvent::Open { block, off: None, .. } = e {
|
||||
let count = table_counts.entry(*block).or_insert(0);
|
||||
open_nth.push(*count);
|
||||
*count += 1;
|
||||
} else {
|
||||
open_nth.push(0);
|
||||
}
|
||||
}
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: index among table opens on the same table.
|
||||
let nth = open_nth.get(k).copied().unwrap_or(0);
|
||||
tables.push((Path(path), nth, true));
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
|
||||
@@ -5,7 +5,9 @@ use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange,
|
||||
use super::position::{Path, Pos, StoryRef};
|
||||
use super::settings::{CoreProps, CustomProp, Settings};
|
||||
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
|
||||
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
|
||||
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
|
||||
@@ -793,7 +795,9 @@ impl Document {
|
||||
p.kind = "lpwstr".into();
|
||||
p.value = value.to_string();
|
||||
}
|
||||
None => self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string() }),
|
||||
None => {
|
||||
self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-1
@@ -434,10 +434,16 @@ impl Document {
|
||||
at.extend(offs.get(k).copied());
|
||||
}
|
||||
}
|
||||
// Reference offsets and run boundaries are ordered; do not restart at the first
|
||||
// reference for every run, including runs after the last reference.
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let end = start.saturating_add(r.len);
|
||||
if at.iter().any(|&o| o >= start && o < end) {
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
swap(&mut r.props.style);
|
||||
}
|
||||
start = end;
|
||||
@@ -792,6 +798,63 @@ impl Document {
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
|
||||
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
|
||||
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
|
||||
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
|
||||
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
|
||||
let t = self.table(story, tp)?.slice(rows, cols);
|
||||
if t.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.fragment_of(vec![Block::Table(t)]))
|
||||
}
|
||||
|
||||
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
|
||||
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
|
||||
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
|
||||
/// outermost one covered whole (a table nested in a cell when the range covers just it).
|
||||
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
|
||||
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
|
||||
let (a, b) = order(a, b);
|
||||
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
|
||||
return None;
|
||||
}
|
||||
let mut paths: Option<Vec<Path>> = None;
|
||||
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
|
||||
// every three steps (block, row, cell) deeper.
|
||||
let mut n = 1usize;
|
||||
while n.saturating_add(3) <= a.path.0.len() {
|
||||
let pre = a.path.0.get(..n)?;
|
||||
if !b.path.0.starts_with(pre) {
|
||||
return None;
|
||||
}
|
||||
let tp = Path(pre.to_vec());
|
||||
if self.table(a.story, &tp).is_some() {
|
||||
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
|
||||
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
|
||||
if let Some(first) = inside.next() {
|
||||
let last = inside.next_back().unwrap_or(first);
|
||||
let end = self.para(a.story, last).map_or(0, Paragraph::len);
|
||||
if *first == a.path && *last == b.path && b.off >= end {
|
||||
return Some(tp);
|
||||
}
|
||||
}
|
||||
}
|
||||
n = n.saturating_add(3);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The whole body as a fragment (with its text boxes), to insert into another document.
|
||||
pub fn body_fragment(&self) -> Fragment {
|
||||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||||
@@ -846,6 +909,13 @@ impl Document {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
|
||||
if let Some(tp) = self.whole_table(&a, &b)
|
||||
&& let Some(blk) = self.block(a.story, &tp)
|
||||
{
|
||||
out.push(blk.clone());
|
||||
return out;
|
||||
}
|
||||
for p in self.paths_between(&a, &b) {
|
||||
if let Some(para) = self.para(a.story, &p) {
|
||||
let mut q = para.clone();
|
||||
|
||||
+62
-57
@@ -1061,63 +1061,8 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
|
||||
*kind = ScriptKind::SubSup;
|
||||
here(if action == "script.addSup" { 2 } else { 1 })
|
||||
}
|
||||
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => {
|
||||
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1);
|
||||
// Row and column of the caret's cell.
|
||||
let mut k = 0;
|
||||
let mut rc = (0, 0);
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
if c < k + row.len() {
|
||||
rc = (r, c - k);
|
||||
break;
|
||||
}
|
||||
k += row.len();
|
||||
}
|
||||
let (r, col) = rc;
|
||||
let (mut tr, mut tc) = (r, col);
|
||||
match a {
|
||||
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if a == "matrix.rowAbove" { r } else { r + 1 };
|
||||
rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]);
|
||||
tr = at;
|
||||
}
|
||||
"matrix.colBefore" | "matrix.colAfter" if ncols < 64 => {
|
||||
let at = if a == "matrix.colBefore" { col } else { col + 1 };
|
||||
for row in rows.iter_mut() {
|
||||
row.insert(at.min(row.len()), Vec::new());
|
||||
}
|
||||
tc = at;
|
||||
}
|
||||
"matrix.deleteRow" if rows.len() > 1 => {
|
||||
rows.remove(r.min(rows.len() - 1));
|
||||
tr = r.min(rows.len() - 1);
|
||||
}
|
||||
"matrix.deleteCol" if ncols > 1 => {
|
||||
for row in rows.iter_mut() {
|
||||
if col < row.len() {
|
||||
row.remove(col);
|
||||
}
|
||||
}
|
||||
tc = col.min(ncols - 2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::<usize>() + tc;
|
||||
here(flat)
|
||||
}
|
||||
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a {
|
||||
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len());
|
||||
rows.insert(at, Vec::new());
|
||||
here(at)
|
||||
}
|
||||
"eqarr.deleteRow" if rows.len() > 1 => {
|
||||
let k = c.min(rows.len() - 1);
|
||||
rows.remove(k);
|
||||
here(k.min(rows.len() - 1))
|
||||
}
|
||||
_ => inner,
|
||||
},
|
||||
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here),
|
||||
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner),
|
||||
(MNode::GroupChr { top, chr, .. }, "group.flip") => {
|
||||
*top = !*top;
|
||||
*chr = match *chr {
|
||||
@@ -1141,6 +1086,66 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
|
||||
Some(clamp(root, &out))
|
||||
}
|
||||
|
||||
fn apply_matrix_action(rows: &mut Vec<Vec<Arg>>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos {
|
||||
let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let (mut row_index, mut column) = (0, 0);
|
||||
let mut offset = 0;
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
if caret < offset + row.len() {
|
||||
row_index = index;
|
||||
column = caret - offset;
|
||||
break;
|
||||
}
|
||||
offset += row.len();
|
||||
}
|
||||
let (mut target_row, mut target_column) = (row_index, column);
|
||||
match action {
|
||||
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
|
||||
let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 };
|
||||
rows.insert(at.min(rows.len()), vec![Vec::new(); columns]);
|
||||
target_row = at;
|
||||
}
|
||||
"matrix.colBefore" | "matrix.colAfter" if columns < 64 => {
|
||||
let at = if action == "matrix.colBefore" { column } else { column + 1 };
|
||||
for row in rows.iter_mut() {
|
||||
row.insert(at.min(row.len()), Vec::new());
|
||||
}
|
||||
target_column = at;
|
||||
}
|
||||
"matrix.deleteRow" if rows.len() > 1 => {
|
||||
rows.remove(row_index.min(rows.len() - 1));
|
||||
target_row = row_index.min(rows.len() - 1);
|
||||
}
|
||||
"matrix.deleteCol" if columns > 1 => {
|
||||
for row in rows.iter_mut() {
|
||||
if column < row.len() {
|
||||
row.remove(column);
|
||||
}
|
||||
}
|
||||
target_column = column.min(columns - 2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let flat = rows.iter().take(target_row).map(Vec::len).sum::<usize>() + target_column;
|
||||
here(flat)
|
||||
}
|
||||
|
||||
fn apply_equation_array_action(rows: &mut Vec<Arg>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos {
|
||||
match action {
|
||||
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
|
||||
let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len());
|
||||
rows.insert(at, Vec::new());
|
||||
here(at)
|
||||
}
|
||||
"eqarr.deleteRow" if rows.len() > 1 => {
|
||||
let index = caret.min(rows.len() - 1);
|
||||
rows.remove(index);
|
||||
here(index.min(rows.len() - 1))
|
||||
}
|
||||
_ => inner,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the limit placement of n-ary operators (`None`: Word's default by operator).
|
||||
pub fn set_limits(nodes: &mut Arg, loc: Option<LimLoc>) {
|
||||
for n in nodes.iter_mut() {
|
||||
|
||||
+233
-214
@@ -863,233 +863,252 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => {
|
||||
let (open, close) = if r.nor {
|
||||
("\\text{", "}")
|
||||
} else {
|
||||
match (r.sty, r.scr) {
|
||||
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
|
||||
(_, MScr::Script) => ("\\mathcal{", "}"),
|
||||
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
|
||||
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
|
||||
(_, MScr::Monospace) => ("\\mathtt{", "}"),
|
||||
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
|
||||
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
|
||||
(Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => {
|
||||
if FUNCTION_NAMES.contains(&r.text.as_str()) {
|
||||
s.push('\\');
|
||||
s.push_str(&r.text);
|
||||
s.push(' ');
|
||||
return;
|
||||
}
|
||||
("\\mathrm{", "}")
|
||||
}
|
||||
_ => ("", ""),
|
||||
}
|
||||
};
|
||||
s.push_str(open);
|
||||
if r.nor {
|
||||
text_chars(&r.text, s);
|
||||
} else {
|
||||
chars(&r.text, s);
|
||||
}
|
||||
s.push_str(close);
|
||||
}
|
||||
MNode::Frac { kind, num, den } => match kind {
|
||||
FracKind::NoBar => {
|
||||
s.push_str("\\genfrac{}{}{0pt}{}");
|
||||
braced(num, s, depth);
|
||||
braced(den, s, depth);
|
||||
}
|
||||
FracKind::Linear | FracKind::Skewed => {
|
||||
braced(num, s, depth);
|
||||
s.push('/');
|
||||
braced(den, s, depth);
|
||||
}
|
||||
FracKind::Bar => {
|
||||
s.push_str("\\frac");
|
||||
braced(num, s, depth);
|
||||
braced(den, s, depth);
|
||||
}
|
||||
},
|
||||
MNode::Script { kind, base, sub, sup } => {
|
||||
if *kind == ScriptKind::Pre {
|
||||
s.push_str("{}_");
|
||||
braced(sub, s, depth);
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
braced(base, s, depth);
|
||||
return;
|
||||
}
|
||||
braced(base, s, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
s.push('_');
|
||||
braced(sub, s, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Rad { deg, deg_hide, e } => {
|
||||
s.push_str("\\sqrt");
|
||||
if !deg_hide && !deg.is_empty() {
|
||||
s.push('[');
|
||||
arg(deg, s, depth);
|
||||
s.push(']');
|
||||
}
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => {
|
||||
match name_of(*chr) {
|
||||
Some(n) => {
|
||||
s.push('\\');
|
||||
s.push_str(n);
|
||||
}
|
||||
None => s.push(*chr),
|
||||
}
|
||||
match lim_loc {
|
||||
Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"),
|
||||
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"),
|
||||
_ => {}
|
||||
}
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
s.push('_');
|
||||
braced(sub, s, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
s.push('^');
|
||||
braced(sup, s, depth);
|
||||
}
|
||||
s.push(' ');
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Delim { beg, end, sep, elems, .. } => {
|
||||
s.push_str("\\left");
|
||||
s.push_str(&delim_tex(*beg));
|
||||
let mid = match sep {
|
||||
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
|
||||
None => "\\middle|".to_string(),
|
||||
};
|
||||
for (i, e) in elems.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str(&mid);
|
||||
}
|
||||
arg(e, s, depth);
|
||||
}
|
||||
s.push_str("\\right");
|
||||
s.push_str(&delim_tex(*end));
|
||||
}
|
||||
fn node(node: &MNode, output: &mut String, depth: usize) {
|
||||
match node {
|
||||
MNode::Run(run) => run_node(run, output),
|
||||
MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth),
|
||||
MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth),
|
||||
MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth),
|
||||
MNode::Nary { .. } => nary_node(node, output, depth),
|
||||
MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth),
|
||||
MNode::Func { name, e } => {
|
||||
arg(name, s, depth);
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Lim { upper, e, lim } => {
|
||||
// `\lim_{…}` keeps its name; other bases stack.
|
||||
let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str()));
|
||||
if simple && !upper {
|
||||
arg(e, s, depth);
|
||||
s.push('_');
|
||||
braced(lim, s, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, s, depth);
|
||||
s.push(if *upper { '^' } else { '_' });
|
||||
braced(lim, s, depth);
|
||||
} else {
|
||||
s.push_str(if *upper { "\\overset" } else { "\\underset" });
|
||||
braced(lim, s, depth);
|
||||
braced(e, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Acc { chr, e } => {
|
||||
let cmd = match chr {
|
||||
'\u{302}' => "hat",
|
||||
'\u{303}' => "tilde",
|
||||
'\u{307}' => "dot",
|
||||
'\u{308}' => "ddot",
|
||||
'\u{20DB}' => "dddot",
|
||||
'\u{30C}' => "check",
|
||||
'\u{306}' => "breve",
|
||||
'\u{301}' => "acute",
|
||||
'\u{300}' => "grave",
|
||||
'\u{305}' | '\u{AF}' => "bar",
|
||||
'\u{20D6}' => "overleftarrow",
|
||||
'\u{20E1}' => "overleftrightarrow",
|
||||
_ => "vec",
|
||||
};
|
||||
s.push('\\');
|
||||
s.push_str(cmd);
|
||||
braced(e, s, depth);
|
||||
arg(name, output, depth);
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth),
|
||||
MNode::Acc { chr, e } => accent_node(*chr, e, output, depth),
|
||||
MNode::Bar { top, e } => {
|
||||
s.push_str(if *top { "\\overline" } else { "\\underline" });
|
||||
braced(e, s, depth);
|
||||
output.push_str(if *top { "\\overline" } else { "\\underline" });
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::BorderBox { e, .. } => {
|
||||
s.push_str("\\boxed");
|
||||
braced(e, s, depth);
|
||||
output.push_str("\\boxed");
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::Boxed { e } => braced(e, s, depth),
|
||||
MNode::Boxed { e } => braced(e, output, depth),
|
||||
MNode::GroupChr { chr, top, e } => {
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::EqArr { rows } => {
|
||||
if rows.len() == 1
|
||||
&& let Some(r) = rows.first()
|
||||
&& crate::math::has_number_mark(r)
|
||||
{
|
||||
arg(r, s, depth);
|
||||
return;
|
||||
}
|
||||
s.push_str("\\begin{aligned}");
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\\\");
|
||||
}
|
||||
for n in r {
|
||||
match n {
|
||||
MNode::Run(run) if !run.lit && run.text.contains('&') => {
|
||||
for (k, part) in run.text.split('&').enumerate() {
|
||||
if k > 0 {
|
||||
s.push('&');
|
||||
}
|
||||
let mut p = run.clone();
|
||||
p.text = part.to_string();
|
||||
node(&MNode::Run(p), s, depth + 1);
|
||||
}
|
||||
}
|
||||
other => node(other, s, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
s.push_str("\\end{aligned}");
|
||||
}
|
||||
MNode::Matrix { rows, .. } => {
|
||||
s.push_str("\\begin{matrix}");
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\\\");
|
||||
}
|
||||
for (j, c) in r.iter().enumerate() {
|
||||
if j > 0 {
|
||||
s.push('&');
|
||||
}
|
||||
arg(c, s, depth);
|
||||
}
|
||||
}
|
||||
s.push_str("\\end{matrix}");
|
||||
output.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, output, depth);
|
||||
}
|
||||
MNode::EqArr { rows } => equation_array_node(rows, output, depth),
|
||||
MNode::Matrix { rows, .. } => matrix_node(rows, output, depth),
|
||||
MNode::Phant { e, .. } => {
|
||||
s.push_str("\\phantom");
|
||||
braced(e, s, depth);
|
||||
output.push_str("\\phantom");
|
||||
braced(e, output, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_node(run: &MRun, output: &mut String) {
|
||||
let (open, close) = if run.nor {
|
||||
("\\text{", "}")
|
||||
} else {
|
||||
match (run.sty, run.scr) {
|
||||
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
|
||||
(_, MScr::Script) => ("\\mathcal{", "}"),
|
||||
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
|
||||
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
|
||||
(_, MScr::Monospace) => ("\\mathtt{", "}"),
|
||||
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
|
||||
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
|
||||
(Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => {
|
||||
if FUNCTION_NAMES.contains(&run.text.as_str()) {
|
||||
output.push('\\');
|
||||
output.push_str(&run.text);
|
||||
output.push(' ');
|
||||
return;
|
||||
}
|
||||
("\\mathrm{", "}")
|
||||
}
|
||||
_ => ("", ""),
|
||||
}
|
||||
};
|
||||
output.push_str(open);
|
||||
if run.nor {
|
||||
text_chars(&run.text, output)
|
||||
} else {
|
||||
chars(&run.text, output)
|
||||
}
|
||||
output.push_str(close);
|
||||
}
|
||||
|
||||
fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) {
|
||||
match kind {
|
||||
FracKind::NoBar => {
|
||||
output.push_str("\\genfrac{}{}{0pt}{}");
|
||||
braced(numerator, output, depth);
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
FracKind::Linear | FracKind::Skewed => {
|
||||
braced(numerator, output, depth);
|
||||
output.push('/');
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
FracKind::Bar => {
|
||||
output.push_str("\\frac");
|
||||
braced(numerator, output, depth);
|
||||
braced(denominator, output, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) {
|
||||
if kind == ScriptKind::Pre {
|
||||
output.push_str("{}_");
|
||||
braced(sub, output, depth);
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
braced(base, output, depth);
|
||||
return;
|
||||
}
|
||||
braced(base, output, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
output.push('_');
|
||||
braced(sub, output, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
|
||||
output.push_str("\\sqrt");
|
||||
if !degree_hidden && !degree.is_empty() {
|
||||
output.push('[');
|
||||
arg(degree, output, depth);
|
||||
output.push(']');
|
||||
}
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
|
||||
fn nary_node(node: &MNode, output: &mut String, depth: usize) {
|
||||
let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return };
|
||||
match name_of(*chr) {
|
||||
Some(name) => {
|
||||
output.push('\\');
|
||||
output.push_str(name);
|
||||
}
|
||||
None => output.push(*chr),
|
||||
}
|
||||
match lim_loc {
|
||||
Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"),
|
||||
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"),
|
||||
_ => {}
|
||||
}
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
output.push('_');
|
||||
braced(sub, output, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
output.push('^');
|
||||
braced(sup, output, depth);
|
||||
}
|
||||
output.push(' ');
|
||||
braced(e, output, depth);
|
||||
}
|
||||
|
||||
fn delimiter_node(begin: Option<char>, end: Option<char>, separator: Option<char>, elements: &[Arg], output: &mut String, depth: usize) {
|
||||
output.push_str("\\left");
|
||||
output.push_str(&delim_tex(begin));
|
||||
let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string());
|
||||
for (index, element) in elements.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str(&middle);
|
||||
}
|
||||
arg(element, output, depth);
|
||||
}
|
||||
output.push_str("\\right");
|
||||
output.push_str(&delim_tex(end));
|
||||
}
|
||||
|
||||
fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) {
|
||||
let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str()));
|
||||
if simple && !upper {
|
||||
arg(expression, output, depth);
|
||||
output.push('_');
|
||||
braced(limit, output, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = expression {
|
||||
output.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, output, depth);
|
||||
output.push(if upper { '^' } else { '_' });
|
||||
braced(limit, output, depth);
|
||||
} else {
|
||||
output.push_str(if upper { "\\overset" } else { "\\underset" });
|
||||
braced(limit, output, depth);
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
}
|
||||
|
||||
fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) {
|
||||
let command = match character {
|
||||
'\u{302}' => "hat",
|
||||
'\u{303}' => "tilde",
|
||||
'\u{307}' => "dot",
|
||||
'\u{308}' => "ddot",
|
||||
'\u{20DB}' => "dddot",
|
||||
'\u{30C}' => "check",
|
||||
'\u{306}' => "breve",
|
||||
'\u{301}' => "acute",
|
||||
'\u{300}' => "grave",
|
||||
'\u{305}' | '\u{AF}' => "bar",
|
||||
'\u{20D6}' => "overleftarrow",
|
||||
'\u{20E1}' => "overleftrightarrow",
|
||||
_ => "vec",
|
||||
};
|
||||
output.push('\\');
|
||||
output.push_str(command);
|
||||
braced(expression, output, depth);
|
||||
}
|
||||
|
||||
fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) {
|
||||
if rows.len() == 1
|
||||
&& let Some(row) = rows.first()
|
||||
&& crate::math::has_number_mark(row)
|
||||
{
|
||||
arg(row, output, depth);
|
||||
return;
|
||||
}
|
||||
output.push_str("\\begin{aligned}");
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str("\\\\");
|
||||
}
|
||||
for child_node in row {
|
||||
match child_node {
|
||||
MNode::Run(run) if !run.lit && run.text.contains('&') => {
|
||||
for (index, part) in run.text.split('&').enumerate() {
|
||||
if index > 0 {
|
||||
output.push('&');
|
||||
}
|
||||
let mut piece = run.clone();
|
||||
piece.text = part.to_string();
|
||||
run_node(&piece, output);
|
||||
}
|
||||
}
|
||||
other => node(other, output, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
output.push_str("\\end{aligned}");
|
||||
}
|
||||
|
||||
fn matrix_node(rows: &[Vec<Arg>], output: &mut String, depth: usize) {
|
||||
output.push_str("\\begin{matrix}");
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
if row_index > 0 {
|
||||
output.push_str("\\\\");
|
||||
}
|
||||
for (column, cell) in row.iter().enumerate() {
|
||||
if column > 0 {
|
||||
output.push('&');
|
||||
}
|
||||
arg(cell, output, depth);
|
||||
}
|
||||
}
|
||||
output.push_str("\\end{matrix}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -501,9 +501,8 @@ impl Paragraph {
|
||||
pub fn next_boundary(&self, off: usize) -> usize {
|
||||
crate::cluster::next_cluster(&self.text, self.clamp(off))
|
||||
}
|
||||
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
|
||||
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
|
||||
/// at a time, otherwise the previous caret stop.
|
||||
/// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the
|
||||
/// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop.
|
||||
pub fn backspace_from(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// How a floating object is placed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::float::{Float, Wrap, cap_alt};
|
||||
use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind};
|
||||
use super::*;
|
||||
use super::{CharProps, Deserialize, Serialize};
|
||||
|
||||
/// An inline object anchored at a U+FFFC in the paragraph text.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize};
|
||||
|
||||
/// Object replacement character: marks an inline object in paragraph text.
|
||||
pub const OBJ: char = '\u{FFFC}';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::block::Blocks;
|
||||
use super::position::Pos;
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize, props};
|
||||
|
||||
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
|
||||
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Which story a position is in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::color::{Rgb, TextColor};
|
||||
use super::para_props::Border;
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -273,6 +273,12 @@ pub struct CharProps {
|
||||
pub small_caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
/// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_hidden: Option<bool>,
|
||||
/// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// Character spacing (expanded/condensed), points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spacing: Option<f32>,
|
||||
@@ -447,24 +453,60 @@ impl CharProps {
|
||||
/// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when
|
||||
/// overlaid on `base`.
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures);
|
||||
self.minus_text_style(base);
|
||||
self.minus_run_visibility(base);
|
||||
self.minus_script_style(base);
|
||||
self.minus_document_marks(base);
|
||||
self
|
||||
}
|
||||
|
||||
fn minus_text_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn minus_run_visibility(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn minus_script_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
fn minus_document_marks(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; link, ins, del, ligatures);
|
||||
}
|
||||
|
||||
/// Apply every `Some` field of `patch` on top of `self`.
|
||||
///
|
||||
/// Plain and complex-script properties inherit independently. Commands that intend to
|
||||
/// format both scripts must set both values explicitly.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures);
|
||||
self.overlay_text_style(patch);
|
||||
self.overlay_run_visibility(patch);
|
||||
self.overlay_script_style(patch);
|
||||
self.overlay_document_marks(patch);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_text_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn overlay_run_visibility(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
fn overlay_document_marks(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
|
||||
self.overlay(patch);
|
||||
self
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// An sRGB colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::char_props::{NumChange, PropChange};
|
||||
use super::color::Rgb;
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -367,10 +367,23 @@ pub struct ParaProps {
|
||||
|
||||
impl ParaProps {
|
||||
pub fn overlay(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing,
|
||||
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
|
||||
snap_to_grid);
|
||||
self.overlay_layout(patch);
|
||||
self.overlay_pagination(patch);
|
||||
self.overlay_script_layout(patch);
|
||||
}
|
||||
|
||||
fn overlay_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers,
|
||||
drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
self.overlay(patch);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::char_props::PropChange;
|
||||
use super::color::Rgb;
|
||||
use super::para_props::{Align, Borders};
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -270,7 +270,12 @@ impl ResolvedPara {
|
||||
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
|
||||
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
|
||||
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
|
||||
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
|
||||
// Explicit zero dimensions cancel a frame; omitted dimensions still fit its text.
|
||||
frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| {
|
||||
f.w = f.w.filter(|v| *v != 0.0);
|
||||
f.h = f.h.filter(|v| *v != 0.0);
|
||||
f
|
||||
}),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Blocks, Deserialize, Document, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Rgb, Serialize, math, section, styles};
|
||||
|
||||
/// A bibliography source.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -10,6 +10,10 @@ pub struct Source {
|
||||
pub kind: String,
|
||||
/// "Last, First; Last, First"
|
||||
pub author: String,
|
||||
/// A corporate author ("World Health Organization"): written as it is, never split into
|
||||
/// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`,
|
||||
/// #4848).
|
||||
pub corporate: String,
|
||||
pub title: String,
|
||||
pub year: String,
|
||||
pub publisher: String,
|
||||
@@ -39,6 +43,9 @@ pub struct CustomProp {
|
||||
/// the property's XML content kept verbatim (vectors, blobs).
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_target: Option<String>,
|
||||
}
|
||||
|
||||
/// Document properties (File › Info).
|
||||
|
||||
@@ -77,6 +77,80 @@ pub const MAX_COLS: usize = 63;
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
/// and whether any cell touches them (Cut clears the same cells it copies).
|
||||
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
|
||||
let ((r0, r1), (g0, g1)) = (rows, cols);
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
|
||||
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
|
||||
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
|
||||
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
|
||||
let mut any = false;
|
||||
for _ in 0..=MAX_COLS.saturating_add(1) {
|
||||
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g < hi && end > lo {
|
||||
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
}
|
||||
any = found;
|
||||
if (nlo, nhi) == (lo, hi) {
|
||||
break;
|
||||
}
|
||||
(lo, hi) = (nlo, nhi);
|
||||
}
|
||||
(lo, hi, any)
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
|
||||
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
|
||||
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
|
||||
/// that continues a vertical merge from a row left out starts the merge instead.
|
||||
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
|
||||
let (r0, r1) = rows;
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
let (lo, hi, any) = self.slice_bounds(rows, cols);
|
||||
let mut out_rows = Vec::new();
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
let mut cells = Vec::new();
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if any && g < hi && end > lo {
|
||||
cells.push(cell.clone());
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
if !cells.is_empty() {
|
||||
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
|
||||
}
|
||||
}
|
||||
if let Some(first) = out_rows.first_mut() {
|
||||
for c in &mut first.cells {
|
||||
if c.props.vmerge == VMerge::Continue {
|
||||
c.props.vmerge = VMerge::Restart;
|
||||
}
|
||||
}
|
||||
}
|
||||
let whole = lo == 0 && hi >= self.grid.len();
|
||||
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
|
||||
let mut props = self.props.clone();
|
||||
if !whole {
|
||||
// Part of the columns: as wide as they are, not the whole table's width.
|
||||
if props.width.is_some() || props.width_pct.is_some() {
|
||||
props.width = Some(grid.iter().sum());
|
||||
}
|
||||
props.width_pct = None;
|
||||
}
|
||||
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
|
||||
}
|
||||
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
@@ -566,6 +640,69 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
|
||||
fn merged() -> Table {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
t.merge(0, 0, 0, 1);
|
||||
t.merge(1, 2, 2, 2);
|
||||
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
|
||||
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
t
|
||||
}
|
||||
|
||||
fn spans(t: &Table) -> Vec<Vec<usize>> {
|
||||
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
|
||||
}
|
||||
|
||||
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
|
||||
/// other rows widen with it, so the copy isn't ragged.
|
||||
#[test]
|
||||
fn slice_widens_to_a_merged_cell_in_every_row() {
|
||||
let t = merged().slice((0, 1), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
|
||||
// Rows without the merged cell: just the selected columns.
|
||||
let t = merged().slice((1, 2), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
|
||||
}
|
||||
|
||||
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
|
||||
#[test]
|
||||
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
|
||||
let t = merged().slice((2, 2), (0, 2));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
|
||||
// Both rows kept: the merge is kept as it is.
|
||||
let t = merged().slice((1, 2), (2, 2));
|
||||
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
}
|
||||
|
||||
/// #1744 review: part of the columns is as wide as they are; content controls around rows
|
||||
/// and the table aren't copied.
|
||||
#[test]
|
||||
fn slice_width_and_controls() {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
|
||||
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
|
||||
t.controls = wrap.clone();
|
||||
t.rows[1].controls = wrap;
|
||||
let part = t.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
|
||||
let whole = t.slice((0, 2), (0, 2));
|
||||
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
|
||||
let row = t.slice((1, 1), (0, 2));
|
||||
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
|
||||
// A table sized only by a percentage keeps a width for the part.
|
||||
let mut pct = Table::new(3, 3, 300.0);
|
||||
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
|
||||
let part = pct.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
|
||||
// Nothing in range: no rows.
|
||||
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
|
||||
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
#[test]
|
||||
fn new_document_is_valid() {
|
||||
let d = Document::new();
|
||||
@@ -651,3 +653,66 @@ fn control_markers_kept_are_sorted() {
|
||||
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_linear_time() {
|
||||
let mut d = Document::new();
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
const COUNT: usize = 2_000;
|
||||
t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect();
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
let timer = std::time::Instant::now();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables.len(), COUNT);
|
||||
for (i, (path, nth, is_open)) in tables.iter().enumerate() {
|
||||
assert_eq!(*path, Path::top(0));
|
||||
assert_eq!(*nth, i);
|
||||
assert!(*is_open);
|
||||
}
|
||||
|
||||
assert!(d.has_unbalanced_controls());
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, COUNT);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed());
|
||||
let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") };
|
||||
assert!(tbl.controls.open.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_mixed_tables_and_paired() {
|
||||
let mut d = Document::new();
|
||||
let mut t0 = Table::new(1, 1, 100.0);
|
||||
t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect();
|
||||
t0.controls.close = 1;
|
||||
|
||||
let mut t1 = Table::new(1, 1, 100.0);
|
||||
t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect();
|
||||
|
||||
d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))];
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]);
|
||||
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, 4);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
|
||||
let Block::Table(res0) = &*d.body[0] else { panic!("expected table") };
|
||||
assert_eq!(res0.controls.open.len(), 1);
|
||||
assert_eq!(res0.controls.open[0].tag, "t0_2");
|
||||
assert_eq!(res0.controls.close, 1);
|
||||
|
||||
let Block::Table(res1) = &*d.body[1] else { panic!("expected table") };
|
||||
assert!(res1.controls.open.is_empty());
|
||||
assert_eq!(res1.controls.close, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
use crate::para::{InlineObject, NoteKind, OBJ};
|
||||
|
||||
fn reference(id: u32, kind: NoteKind) -> InlineObject {
|
||||
InlineObject::NoteRef { kind, id, custom: "*".into() }
|
||||
}
|
||||
|
||||
fn styled(len: usize, style: &str) -> Run {
|
||||
Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() {
|
||||
for (foot, end, expected) in [
|
||||
(true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]),
|
||||
(false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
(true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
] {
|
||||
let mut d = Document::new();
|
||||
let note = |style: &str| {
|
||||
let mut p = Paragraph::with_text("Original note 漢", CharProps::default());
|
||||
p.props.style = Some(style.into());
|
||||
p.mark.style = Some(style.into());
|
||||
vec![para_block(p)]
|
||||
};
|
||||
let f = d.add_part(PartKind::Footnote, note("FootnoteText"));
|
||||
let e = d.add_part(PartKind::Endnote, note("EndnoteText"));
|
||||
let p = Paragraph {
|
||||
text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"),
|
||||
objects: vec![
|
||||
reference(f, NoteKind::Footnote),
|
||||
InlineObject::BookmarkStart { name: "kept".into() },
|
||||
reference(e, NoteKind::Endnote),
|
||||
reference(999, NoteKind::Footnote),
|
||||
reference(f, NoteKind::Footnote),
|
||||
],
|
||||
runs: vec![
|
||||
styled(2, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
],
|
||||
..Paragraph::new()
|
||||
};
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p.clone())];
|
||||
d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))];
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]);
|
||||
let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]);
|
||||
let before = d.clone();
|
||||
assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end));
|
||||
for (story, path) in [
|
||||
(StoryRef::Body, Path::top(0)),
|
||||
(StoryRef::Body, Path(vec![1, 0, 0, 0])),
|
||||
(StoryRef::Part(h), Path::top(0)),
|
||||
(StoryRef::Part(t), Path::top(0)),
|
||||
] {
|
||||
let got = d.para(story, &path).unwrap();
|
||||
assert_eq!(got.text, p.text);
|
||||
assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(), expected);
|
||||
assert!(got.runs.iter().all(|r| r.props.italic == Some(true)));
|
||||
assert_eq!(got.objects[1], p.objects[1]);
|
||||
assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged");
|
||||
assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote }));
|
||||
assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote }));
|
||||
assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion");
|
||||
}
|
||||
for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] {
|
||||
let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap();
|
||||
assert_eq!(got.text, before.plain_text(StoryRef::Part(id)));
|
||||
assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from }));
|
||||
assert_eq!(got.mark.style, got.props.style);
|
||||
}
|
||||
let mut missing = Paragraph {
|
||||
text: format!("{OBJ}{OBJ}"),
|
||||
objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })],
|
||||
runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")],
|
||||
..Paragraph::new()
|
||||
};
|
||||
missing.runs.push(styled(usize::MAX, "FootnoteReference"));
|
||||
d.body = vec![para_block(missing)];
|
||||
d.convert_notes(true, true);
|
||||
let got = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(
|
||||
got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dense(n: usize) -> Document {
|
||||
let mut d = Document::new();
|
||||
let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]);
|
||||
d.body = vec![para_block(Paragraph {
|
||||
text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)),
|
||||
objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(),
|
||||
runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(),
|
||||
..Paragraph::new()
|
||||
})];
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
let convert = |input: &Document| {
|
||||
let mut d = input.clone();
|
||||
assert_eq!(d.convert_notes(true, false), 1);
|
||||
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let n = p.objects.len();
|
||||
assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference")));
|
||||
assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference")));
|
||||
std::hint::black_box(d);
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::block::{Block, Blocks};
|
||||
use super::parts::PartKind;
|
||||
use super::position::Path;
|
||||
use super::*;
|
||||
use super::{InlineObject, Numbering, numbering, para, section, styles};
|
||||
|
||||
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
|
||||
pub fn count_words(s: &str) -> usize {
|
||||
|
||||
@@ -90,13 +90,16 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
let tag = child_text(src_el, "Tag");
|
||||
let raw_kind = child_text(src_el, "SourceType");
|
||||
let kind = crate::write::from_source_type(&raw_kind);
|
||||
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
let (author, corporate, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
Some(a) => {
|
||||
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
let role = a.els().find(|e| e.local() == "Author");
|
||||
let auth = role.map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
// Word's Corporate Author: one name, kept whole (`b:Corporate`, #4848).
|
||||
let corporate = role.map(|r| child_text(r, "Corporate").trim().to_string()).unwrap_or_default();
|
||||
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
(auth, corporate, ed)
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
None => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
|
||||
let title = child_text(src_el, "Title");
|
||||
@@ -132,11 +135,12 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
};
|
||||
|
||||
// Keep sources with some content, once each (by tag, or title and author untagged).
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && corporate.is_empty() && editor.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
let who = [&author, &corporate, &editor].into_iter().find(|w| !w.is_empty()).cloned().unwrap_or_default();
|
||||
seen.untagged.insert((title.clone(), who))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
@@ -147,6 +151,7 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
corporate,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
@@ -265,6 +270,25 @@ mod tests {
|
||||
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
|
||||
}
|
||||
|
||||
/// #4848: a corporate author (Word's Corporate Author box, `b:Corporate`) is read whole, and
|
||||
/// an untagged source with only a corporate author is still kept, once.
|
||||
#[test]
|
||||
fn a_corporate_author_is_read_whole() {
|
||||
let xml = br#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography">
|
||||
<b:Source><b:Tag>Wor20</b:Tag><b:SourceType>Report</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate> World Health Organization </b:Corporate></b:Author></b:Author>
|
||||
<b:Title>World report on hearing</b:Title><b:Year>2020</b:Year></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
</b:Sources>"#;
|
||||
let f = read_sources(xml).unwrap();
|
||||
assert_eq!(f.sources.len(), 2, "{:?}", f.sources);
|
||||
assert_eq!((f.sources[0].corporate.as_str(), f.sources[0].author.as_str()), ("World Health Organization", ""));
|
||||
assert_eq!(f.sources[1].corporate, "Harbor Guild, Inc.", "a comma doesn't split a corporate name");
|
||||
}
|
||||
|
||||
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
|
||||
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
|
||||
#[test]
|
||||
|
||||
@@ -193,6 +193,25 @@ fn hostile_encryption_info_errors_without_panicking() {
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
/// The key a real-world Standard-encrypted package derives: Apache POI's test file
|
||||
/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel
|
||||
/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code —
|
||||
/// the file decrypts to a valid package under it and nothing else opens that file — so real
|
||||
/// Word 2007 documents stay pinned, not just spec-derived ones.
|
||||
#[test]
|
||||
fn standard_key_matches_a_real_world_package() {
|
||||
let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e];
|
||||
const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c";
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY);
|
||||
// A different password or salt gives a different key; the key is exactly the length
|
||||
// asked for, for each AES width.
|
||||
assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY);
|
||||
assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY);
|
||||
assert_eq!(standard_key(&salt, "tika", 24).len(), 24);
|
||||
assert_eq!(standard_key(&salt, "tika", 32).len(), 32);
|
||||
}
|
||||
|
||||
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
|
||||
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
|
||||
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
|
||||
|
||||
@@ -25,11 +25,11 @@ pub fn read(root: &El) -> Vec<CustomProp> {
|
||||
let Some(v) = p.els().next() else { continue };
|
||||
let kind = v.local();
|
||||
let prop = if SIMPLE.contains(&kind) {
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() }
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
} else {
|
||||
let mut xml = String::new();
|
||||
el_xml(v, &mut xml, 0);
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml }
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
};
|
||||
out.push(prop);
|
||||
}
|
||||
@@ -51,7 +51,12 @@ pub fn write(props: &[CustomProp]) -> Vec<u8> {
|
||||
if p.kind == "raw" && !raw_is_wellformed(&p.value) {
|
||||
continue;
|
||||
}
|
||||
w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]);
|
||||
let pid = pid.to_string();
|
||||
let mut attrs = vec![("fmtid", FMTID), ("pid", &pid), ("name", &p.name)];
|
||||
if let Some(target) = &p.link_target {
|
||||
attrs.push(("linkTarget", target));
|
||||
}
|
||||
w.open("property", &attrs);
|
||||
if p.kind == "raw" {
|
||||
w.raw(&p.value);
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest};
|
||||
|
||||
/// Most font files taken from one document.
|
||||
const MAX_FONTS: usize = 64;
|
||||
@@ -41,11 +41,15 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
/// [`embedded_fonts`], copying at most `budget` bytes of font files.
|
||||
fn fonts_within(doc: &Document, mut budget: u64) -> Vec<EmbeddedFont> {
|
||||
let budget_total = budget;
|
||||
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
|
||||
// The table is kept under the spelling the package stores it as (#3872).
|
||||
let Some(key) = crate::package::font_table_key(&doc.passthrough) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
|
||||
let Some(part) = manifest.parts.get(FONT_TABLE_PART) else {
|
||||
let Some(table) = doc.passthrough.get(key).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(part) = manifest.parts.get(key) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
@@ -118,6 +122,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::package::FONT_TABLE_PART;
|
||||
|
||||
/// A document whose font table makes `refs` references (`(font name, part)`) to font parts
|
||||
/// `parts` (`(name, size)`).
|
||||
|
||||
@@ -96,6 +96,15 @@ pub const MAX_VBA_RELATED: usize = 64;
|
||||
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
|
||||
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
|
||||
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
|
||||
|
||||
/// The spelling the font table is stored under in `parts` (part names are case-insensitive, so a
|
||||
/// package may spell it `WORD/FONTTABLE.XML`), preferring the exact [`FONT_TABLE_PART`].
|
||||
pub fn font_table_key<V>(parts: &BTreeMap<String, V>) -> Option<&str> {
|
||||
if let Some((k, _)) = parts.get_key_value(FONT_TABLE_PART) {
|
||||
return Some(k.as_str());
|
||||
}
|
||||
parts.keys().find(|k| k.eq_ignore_ascii_case(FONT_TABLE_PART)).map(String::as_str)
|
||||
}
|
||||
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
|
||||
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
|
||||
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
|
||||
@@ -412,7 +421,8 @@ fn rel_targets(files: &BTreeMap<String, Arc<Vec<u8>>>) -> HashSet<String> {
|
||||
let mut named = HashSet::new();
|
||||
for (name, bytes) in files {
|
||||
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
|
||||
let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue };
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let Some(at) = lower.rfind("_rels/").filter(|_| lower.ends_with(".rels")) else { continue };
|
||||
let dir = name.get(..at).unwrap_or("").trim_end_matches('/');
|
||||
let Ok(root) = xml::parse(bytes) else { continue };
|
||||
for r in root.els().filter(|e| e.local() == "Relationship") {
|
||||
|
||||
@@ -210,18 +210,20 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
lenient("settings", r.read_settings(&p));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
// Comments before notes: a comment range inside a footnote or endnote resolves its id against
|
||||
// the comments already read, and was dropped when the notes came first.
|
||||
if let Some(p) = part(rt::COMMENTS) {
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
let ids = part(rt::COMMENTS_IDS);
|
||||
let cex = part(rt::COMMENTS_EXTENSIBLE);
|
||||
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
|
||||
// package root or the main part; Word's own files sometimes leave the relationship out, so
|
||||
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::model::Pos;
|
||||
use super::*;
|
||||
use super::{CHAR_W, GAP, GraphicItem, LEGEND_SHARE, LINE, MAX_LEGEND_ROWS, PAD, PathSeg, Rgb, ShapeKind, TEXT, TextAlign};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Rect {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::Rgb;
|
||||
|
||||
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
|
||||
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::format::{date_text, excel_stamp, format_value, is_date_code};
|
||||
use super::model::{Axis, Grouping, Kind, Pos, Series};
|
||||
use super::plot::label;
|
||||
use super::*;
|
||||
use super::{Chart, El, FONT, LINE_W, MAX_CELLS, MAX_POINTS, MAX_RADAR_SERIES, MAX_SERIES, MAX_SPOKES, Rgb, fill_colour, int, measure, slot};
|
||||
|
||||
impl Chart {
|
||||
pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option<Chart> {
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::format::format_value;
|
||||
use super::model::{DataLabel, Grouping, Kind, Series};
|
||||
use super::parse::category_text;
|
||||
use super::polar::{draw_data_labels, label_of};
|
||||
use super::*;
|
||||
use super::{AXIS, Chart, El, GAP, GRID, GraphicItem, LINE, MAX_LABEL, MAX_TICKS, PathSeg, Rgb, ShapeKind, TextAlign, fill_colour, measure};
|
||||
|
||||
pub(super) fn label(s: &str) -> String {
|
||||
s.chars().take(MAX_LABEL).collect()
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest};
|
||||
use super::format::format_value;
|
||||
use super::model::{DataLabel, Kind, Series};
|
||||
use super::plot::{Axes, has_values};
|
||||
use super::*;
|
||||
use super::{Chart, FRAC_PI_2, GAP, GRID, GraphicItem, LINE, PathSeg, Rgb, ShapeKind, TAU, TEXT, TextAlign};
|
||||
|
||||
pub(super) fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
||||
let Some(s) = c.series.first() else { return };
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph,
|
||||
PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
|
||||
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Keep the parts the VBA project at `project` relates to, with their relationship types and
|
||||
|
||||
@@ -23,8 +23,8 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string();
|
||||
let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string);
|
||||
wordcraft_doc::props::TextFrame {
|
||||
w: len("w:w").filter(|v| *v > 0.0),
|
||||
h: len("w:h").map(f32::abs).filter(|v| *v > 0.0),
|
||||
w: len("w:w").filter(|v| *v >= 0.0),
|
||||
h: len("w:h").map(f32::abs),
|
||||
h_exact: k.attr("w:hRule") == Some("exact"),
|
||||
x: len("w:x").unwrap_or(0.0),
|
||||
y: len("w:y").unwrap_or(0.0),
|
||||
@@ -258,6 +258,8 @@ impl PropCtx {
|
||||
"w:imprint" => c.engrave = Some(on_off(k)),
|
||||
"w:noProof" => c.no_proof = Some(on_off(k)),
|
||||
"w:vanish" => c.hidden = Some(on_off(k)),
|
||||
"w:webHidden" => c.web_hidden = Some(on_off(k)),
|
||||
"w:snapToGrid" => c.snap_to_grid = Some(on_off(k)),
|
||||
"w:rtl" => c.rtl = Some(on_off(k)),
|
||||
"w:color" => {
|
||||
c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, Block, Blocks, Cell, CharProps, El, InlineObject, MAX_COLS, MAX_ROWS, MAX_TABLE_DEPTH, Node, NumChange, PB, Paragraph, Phase, PropChange,
|
||||
Reader, Rels, RevisionKind, Row, Run, RunCtx, StoryCtx, Table, children_of_choice, clean_text, collect, last_para_mut, para_block,
|
||||
placeholder_of, sectpr, tcpr, trpr,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Read a story's blocks from a container element (`w:body`, `w:hdr`, `w:tc`, `w:txbxContent`…).
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
AltText, Anchor, Arc, Blocks, CHARTEX_URI, ConnEnd, ContentTypes, El, Embedded, Float, FloatAlign, Glow, Graphic, GraphicItem, GraphicKind,
|
||||
INK_REL, INK_URI, InlineObject, MAX_STORY_DEPTH, Node, PartKind, Reader, Rels, Rgb, Shadow, ShapeEffects, ShapeExtra, ShapeKind, StoryCtx,
|
||||
TextWarp, Wrap, WrapText, cap_alt, children_of_choice, int, measure, on_off_attr, own_fill, preset_kind, rt, shape_paint, u32_of,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
// ---- drawings ----
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx};
|
||||
|
||||
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
|
||||
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
|
||||
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_inline_children(&mut self, sc: &mut StoryCtx, pb: &mut PB, e: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
|
||||
@@ -105,7 +108,7 @@ impl Reader<'_> {
|
||||
// Some generators write a break directly under the paragraph instead of inside a run.
|
||||
"w:br" | "w:cr" => {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.read_run_child(sc, pb, k, &props, rels, &mut None, depth + 1);
|
||||
self.read_run_child(sc, pb, k, (&props, rels), &mut None, depth + 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -131,21 +134,19 @@ impl Reader<'_> {
|
||||
};
|
||||
let mut note: Option<(NoteKind, u32, Option<String>)> = None;
|
||||
for k in r.els() {
|
||||
self.read_run_child(sc, pb, k, &props, rels, &mut note, depth + 1);
|
||||
self.read_run_child(sc, pb, k, (&props, rels), &mut note, depth + 1);
|
||||
}
|
||||
if let Some((kind, id, custom)) = note.take() {
|
||||
self.emit_obj(sc, pb, InlineObject::NoteRef { kind, id, custom: custom.unwrap_or_default() }, &props);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn read_run_child(
|
||||
&mut self,
|
||||
sc: &mut StoryCtx,
|
||||
pb: &mut PB,
|
||||
k: &El,
|
||||
props: &CharProps,
|
||||
rels: &Rels,
|
||||
(props, rels): (&CharProps, &Rels),
|
||||
note: &mut Option<(NoteKind, u32, Option<String>)>,
|
||||
depth: usize,
|
||||
) {
|
||||
@@ -301,7 +302,7 @@ impl Reader<'_> {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, props, rels, note, depth + 1);
|
||||
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Anchor, Arc, CharProps, El, Float, FloatAlign, InlineObject, MAX_STORY_DEPTH, MAX_VML_GROUP_DEPTH, Paragraph, PartKind, Reader, Rels, Rgb,
|
||||
ShapeExtra, ShapeKind, StoryCtx, TextColor, TextWarp, Wrap, int, measure, para_block, vml_color, vml_fill, vml_fraction, vml_preset_kind,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct VmlGroupSpace {
|
||||
origin: (f32, f32),
|
||||
scale: (f32, f32),
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Every legacy VML drawing in one `w:pict`, in document order: a `w:pict` may hold any
|
||||
@@ -43,7 +53,7 @@ impl Reader<'_> {
|
||||
let (ch_w, ch_h) = vml_coordpair(grp, "coordsize").filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((w.max(1.0), h.max(1.0)));
|
||||
let (cx, cy) = vml_coordpair(grp, "coordorigin").unwrap_or((0.0, 0.0));
|
||||
let mut children = Vec::new();
|
||||
self.vml_group_members(sc, pict, grp, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
|
||||
self.vml_group_members(sc, pict, grp, rels, VmlGroupSpace { origin: (-cx, -cy), scale: (1.0, 1.0), depth: 0 }, &mut children);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -59,11 +69,11 @@ impl Reader<'_> {
|
||||
pict: &El,
|
||||
g: &El,
|
||||
rels: &Rels,
|
||||
t: (f32, f32, f32, f32),
|
||||
depth: usize,
|
||||
space: VmlGroupSpace,
|
||||
out: &mut Vec<wordcraft_doc::para::GroupChild>,
|
||||
) {
|
||||
let (x0, y0, sx, sy) = t;
|
||||
let (x0, y0) = space.origin;
|
||||
let (sx, sy) = space.scale;
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
|
||||
for e in g.els() {
|
||||
@@ -77,7 +87,7 @@ impl Reader<'_> {
|
||||
let num = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).map_or(0.0, wordcraft_geom::finite);
|
||||
let ((ox, oy), (ew, eh)) = ((num("left"), num("top")), (num("width"), num("height")));
|
||||
if e.local() == "group" {
|
||||
if depth >= MAX_VML_GROUP_DEPTH {
|
||||
if space.depth >= MAX_VML_GROUP_DEPTH {
|
||||
continue;
|
||||
}
|
||||
// Its members' space maps onto its own box.
|
||||
@@ -85,7 +95,7 @@ impl Reader<'_> {
|
||||
let (cox, coy) = vml_coordpair(e, "coordorigin").unwrap_or((0.0, 0.0));
|
||||
let (kx, ky) = (ew / cw, eh / ch);
|
||||
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
|
||||
self.vml_group_members(sc, pict, e, rels, nt, depth + 1, out);
|
||||
self.vml_group_members(sc, pict, e, rels, VmlGroupSpace { origin: (nt.0, nt.1), scale: (nt.2, nt.3), depth: space.depth + 1 }, out);
|
||||
continue;
|
||||
}
|
||||
let Some(mut obj) = self.read_vml_shape(sc, pict, e, rels, Some((sx, sy))) else { continue };
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
|
||||
VAlign, flag, int, props, tw, u32_of,
|
||||
};
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
|
||||
@@ -39,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String {
|
||||
n.to_string()
|
||||
}
|
||||
|
||||
/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`.
|
||||
fn builtin_heading_level(name: &str) -> Option<u8> {
|
||||
let name = name.trim();
|
||||
let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?;
|
||||
digit.parse::<u8>().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1)
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
|
||||
@@ -112,7 +122,7 @@ impl Reader<'_> {
|
||||
priority: s.child_val("w:uiPriority").and_then(u32_of),
|
||||
quick: flag(s, "w:qFormat").unwrap_or(false),
|
||||
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")),
|
||||
locked: flag(s, "w:locked").unwrap_or(false),
|
||||
// Only where the writer couldn't tell from the id (it flags the English ids).
|
||||
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
|
||||
@@ -128,6 +138,16 @@ impl Reader<'_> {
|
||||
if let Some(p) = s.child("w:pPr") {
|
||||
st.para = self.pc.ppr(p).0;
|
||||
}
|
||||
// Word gives its built-in heading styles their outline level whether or not the file
|
||||
// writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the
|
||||
// Navigation pane and the table of contents, whatever its id (#4696).
|
||||
if kind == StyleKind::Paragraph
|
||||
&& st.builtin
|
||||
&& st.para.outline_level.is_none()
|
||||
&& let Some(level) = s.child_val("w:name").and_then(builtin_heading_level)
|
||||
{
|
||||
st.para.outline_level = Some(level);
|
||||
}
|
||||
if let Some(rp) = s.child("w:rPr") {
|
||||
st.chr = self.pc.rpr(rp);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, Block, Blocks, ControlEvent, ControlKind, Document, HashSet, InlineObject, OBJ, StyleKind, control_events, pair_events};
|
||||
|
||||
/// Word draws a table of contents' entry links without the Hyperlink character style (no blue,
|
||||
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
|
||||
|
||||
@@ -805,16 +805,17 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
|
||||
|
||||
/// A layout node: `alg` with its parameters, a shape (`None`: no geometry), what text it shows
|
||||
/// (`presOf` attributes), then `body`.
|
||||
fn layout_node(
|
||||
w: &mut W,
|
||||
name: &str,
|
||||
style: Option<&str>,
|
||||
alg: &str,
|
||||
params: &[(&str, &str)],
|
||||
shape: Option<&str>,
|
||||
pres_of: &[(&str, &str)],
|
||||
body: impl FnOnce(&mut W),
|
||||
) {
|
||||
struct LayoutNode<'a> {
|
||||
name: &'a str,
|
||||
style: Option<&'a str>,
|
||||
alg: &'a str,
|
||||
params: &'a [(&'a str, &'a str)],
|
||||
shape: Option<&'a str>,
|
||||
pres_of: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
fn layout_node(w: &mut W, node: LayoutNode<'_>, body: impl FnOnce(&mut W)) {
|
||||
let LayoutNode { name, style, alg, params, shape, pres_of } = node;
|
||||
let mut attrs = vec![("name", name)];
|
||||
if let Some(s) = style {
|
||||
attrs.push(("styleLbl", s));
|
||||
@@ -870,45 +871,74 @@ pub fn layout_xml(l: SmartArtLayout) -> String {
|
||||
SmartArtLayout::Cycle | SmartArtLayout::Radial | SmartArtLayout::Venn => "ellipse",
|
||||
SmartArtLayout::Pyramid => "trapezoid",
|
||||
};
|
||||
layout_node(&mut w, "diagram", None, alg, params, None, &[], |w| match l {
|
||||
layout_node(&mut w, LayoutNode { name: "diagram", style: None, alg, params, shape: None, pres_of: &[] }, |w| match l {
|
||||
SmartArtLayout::Hierarchy => {
|
||||
w.open("dgm:forEach", &[("name", "branches"), ("axis", "ch"), ("ptType", "node")]);
|
||||
layout_node(w, "branch", None, "hierRoot", &[], None, &[], |w| {
|
||||
layout_node(w, "item", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |_| {});
|
||||
layout_node(w, "below", None, "hierChild", &[("chAlign", "t")], None, &[], |w| {
|
||||
w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]);
|
||||
layout_node(
|
||||
w,
|
||||
"join",
|
||||
Some("connector"),
|
||||
"conn",
|
||||
&[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")],
|
||||
Some("conn"),
|
||||
&[("axis", "self")],
|
||||
|_| {},
|
||||
);
|
||||
w.close("dgm:forEach");
|
||||
w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]);
|
||||
});
|
||||
layout_node(w, LayoutNode { name: "branch", style: None, alg: "hierRoot", params: &[], shape: None, pres_of: &[] }, |w| {
|
||||
layout_node(
|
||||
w,
|
||||
LayoutNode { name: "item", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] },
|
||||
|_| {},
|
||||
);
|
||||
layout_node(
|
||||
w,
|
||||
LayoutNode { name: "below", style: None, alg: "hierChild", params: &[("chAlign", "t")], shape: None, pres_of: &[] },
|
||||
|w| {
|
||||
w.open("dgm:forEach", &[("name", "joins"), ("axis", "ch"), ("ptType", "parTrans")]);
|
||||
layout_node(
|
||||
w,
|
||||
LayoutNode {
|
||||
name: "join",
|
||||
style: Some("connector"),
|
||||
alg: "conn",
|
||||
params: &[("dim", "1D"), ("connRout", "bend"), ("begPts", "bCtr"), ("endPts", "tCtr")],
|
||||
shape: Some("conn"),
|
||||
pres_of: &[("axis", "self")],
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
w.close("dgm:forEach");
|
||||
w.empty("dgm:forEach", &[("name", "deeper"), ("ref", "branches")]);
|
||||
},
|
||||
);
|
||||
});
|
||||
w.close("dgm:forEach");
|
||||
}
|
||||
SmartArtLayout::Radial => {
|
||||
w.open("dgm:forEach", &[("name", "middle"), ("axis", "ch"), ("ptType", "node"), ("cnt", "1")]);
|
||||
layout_node(w, "centre", Some("node1"), "tx", &[], Some(shape), &[("axis", "self")], |w| {
|
||||
w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]);
|
||||
layout_node(w, "spoke", Some("node1"), "tx", &[], Some(shape), ITEM_TEXT, |_| {});
|
||||
w.close("dgm:forEach");
|
||||
});
|
||||
layout_node(
|
||||
w,
|
||||
LayoutNode { name: "centre", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: &[("axis", "self")] },
|
||||
|w| {
|
||||
w.open("dgm:forEach", &[("name", "around"), ("axis", "ch"), ("ptType", "node")]);
|
||||
layout_node(
|
||||
w,
|
||||
LayoutNode { name: "spoke", style: Some("node1"), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT },
|
||||
|_| {},
|
||||
);
|
||||
w.close("dgm:forEach");
|
||||
},
|
||||
);
|
||||
w.close("dgm:forEach");
|
||||
}
|
||||
_ => {
|
||||
w.open("dgm:forEach", &[("name", "items"), ("axis", "ch"), ("ptType", "node")]);
|
||||
let style = if l == SmartArtLayout::Venn { "venn" } else { "node1" };
|
||||
layout_node(w, "item", Some(style), "tx", &[], Some(shape), ITEM_TEXT, |_| {});
|
||||
layout_node(w, LayoutNode { name: "item", style: Some(style), alg: "tx", params: &[], shape: Some(shape), pres_of: ITEM_TEXT }, |_| {});
|
||||
if matches!(l, SmartArtLayout::Process | SmartArtLayout::Cycle) {
|
||||
w.open("dgm:forEach", &[("name", "arrows"), ("axis", "followSib"), ("ptType", "sibTrans"), ("cnt", "1")]);
|
||||
layout_node(w, "arrow", Some("connector"), "conn", &[("dim", "2D")], Some("rightArrow"), &[("axis", "self")], |_| {});
|
||||
layout_node(
|
||||
w,
|
||||
LayoutNode {
|
||||
name: "arrow",
|
||||
style: Some("connector"),
|
||||
alg: "conn",
|
||||
params: &[("dim", "2D")],
|
||||
shape: Some("rightArrow"),
|
||||
pres_of: &[("axis", "self")],
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
w.close("dgm:forEach");
|
||||
}
|
||||
w.close("dgm:forEach");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Document, W};
|
||||
|
||||
fn to_source_type(kind: &str) -> &str {
|
||||
match kind.to_ascii_lowercase().as_str() {
|
||||
@@ -121,10 +121,16 @@ pub(super) fn bibliography_xml(doc: &Document) -> Vec<u8> {
|
||||
w.open("b:Source", &[]);
|
||||
w.leaf("b:Tag", &[], &src.tag);
|
||||
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
|
||||
// Author & Editor
|
||||
if !src.author.is_empty() || !src.editor.is_empty() {
|
||||
// Author & Editor. A role holds a name list or a corporate name, not both (ECMA-376
|
||||
// `CT_NameOrCorporateType`), so a corporate author takes the author's place (#4848).
|
||||
let corporate = src.corporate.trim();
|
||||
if !src.author.is_empty() || !corporate.is_empty() || !src.editor.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
if !src.author.is_empty() {
|
||||
if !corporate.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
w.leaf("b:Corporate", &[], corporate);
|
||||
w.close("b:Author");
|
||||
} else if !src.author.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
write_name_list(&mut w, &src.author);
|
||||
w.close("b:Author");
|
||||
|
||||
@@ -14,8 +14,7 @@ use wordcraft_doc::para::Float;
|
||||
|
||||
use super::PartRels;
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED,
|
||||
rel_is, relative, rt,
|
||||
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
|
||||
};
|
||||
use crate::units::{MAX_LEN_PT, emu, int};
|
||||
use crate::xml::{self, El, MAX_DEPTH};
|
||||
@@ -105,7 +104,8 @@ impl<'d> EmbedWriter<'d> {
|
||||
/// Write the font table kept from a file, with the fonts it embeds, relating it from `rels`.
|
||||
pub fn font_table(&mut self, rels: &mut PartRels) {
|
||||
let mut written = Vec::new();
|
||||
if let Some(name) = self.copy_tree(FONT_TABLE_PART, &mut HashMap::new(), &mut written) {
|
||||
let Some(root) = crate::package::font_table_key(self.passthrough).map(str::to_string) else { return };
|
||||
if let Some(name) = self.copy_tree(&root, &mut HashMap::new(), &mut written) {
|
||||
rels.add(rt::FONT_TABLE, &relative(STORY_PART, &name), false);
|
||||
}
|
||||
}
|
||||
@@ -747,7 +747,25 @@ fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option<Vec<u8>> {
|
||||
b"date" if dates && wml_attr => None,
|
||||
b"dateUtc" if dates => None,
|
||||
_ => {
|
||||
el.push_attribute(a);
|
||||
// `push_attribute` writes the value as given, inside `"`. In well-formed
|
||||
// markup the raw value holds no `<` and every `&` starts a valid
|
||||
// reference, so only a `"` from a single-quoted value needs escaping;
|
||||
// references such as ` ` stay as written (#3872).
|
||||
let raw = a.value.as_ref();
|
||||
let value = if raw.contains(&b'"') {
|
||||
let mut v = Vec::with_capacity(raw.len() + 8);
|
||||
for &c in raw {
|
||||
if c == b'"' {
|
||||
v.extend_from_slice(b""");
|
||||
} else {
|
||||
v.push(c);
|
||||
}
|
||||
}
|
||||
Cow::Owned(v)
|
||||
} else {
|
||||
Cow::Borrowed(raw)
|
||||
};
|
||||
el.push_attribute(Attribute { key: a.key, value });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,6 +179,67 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
|
||||
val(w, name, &s);
|
||||
}
|
||||
|
||||
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
|
||||
let MNode::Matrix { rows, col_jc } = n else { return };
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
for c in 0..cols {
|
||||
let jc = match col_jc.get(c).copied().unwrap_or_default() {
|
||||
ColJc::Center => "center",
|
||||
ColJc::Left => "left",
|
||||
ColJc::Right => "right",
|
||||
};
|
||||
w.open("m:mc", &[]);
|
||||
w.open("m:mcPr", &[]);
|
||||
val(w, "m:count", "1");
|
||||
val(w, "m:mcJc", jc);
|
||||
w.close("m:mcPr");
|
||||
w.close("m:mc");
|
||||
}
|
||||
w.close("m:mcs");
|
||||
ctrl(w);
|
||||
w.close("m:mPr");
|
||||
for row in rows {
|
||||
w.open("m:mr", &[]);
|
||||
for c in 0..cols {
|
||||
arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth);
|
||||
}
|
||||
w.close("m:mr");
|
||||
}
|
||||
w.close("m:m");
|
||||
}
|
||||
|
||||
fn write_nary(w: &mut W, n: &MNode, depth: usize) {
|
||||
let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return };
|
||||
w.open("m:nary", &[]);
|
||||
w.open("m:naryPr", &[]);
|
||||
if *chr != '∫' {
|
||||
char_prop(w, "m:chr", Some(*chr));
|
||||
}
|
||||
match lim_loc {
|
||||
Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"),
|
||||
Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"),
|
||||
None => {}
|
||||
}
|
||||
if *grow {
|
||||
val(w, "m:grow", "1");
|
||||
}
|
||||
if *sub_hide {
|
||||
val(w, "m:subHide", "1");
|
||||
}
|
||||
if *sup_hide {
|
||||
val(w, "m:supHide", "1");
|
||||
}
|
||||
ctrl(w);
|
||||
w.close("m:naryPr");
|
||||
arg(w, "m:sub", sub, depth);
|
||||
arg(w, "m:sup", sup, depth);
|
||||
arg(w, "m:e", e, depth);
|
||||
w.close("m:nary");
|
||||
}
|
||||
|
||||
fn node(w: &mut W, n: &MNode, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => run(w, r),
|
||||
@@ -235,33 +296,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) {
|
||||
arg(w, "m:e", e, depth);
|
||||
w.close("m:rad");
|
||||
}
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
|
||||
w.open("m:nary", &[]);
|
||||
w.open("m:naryPr", &[]);
|
||||
if *chr != '∫' {
|
||||
char_prop(w, "m:chr", Some(*chr));
|
||||
}
|
||||
match lim_loc {
|
||||
Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"),
|
||||
Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"),
|
||||
None => {}
|
||||
}
|
||||
if *grow {
|
||||
val(w, "m:grow", "1");
|
||||
}
|
||||
if *sub_hide {
|
||||
val(w, "m:subHide", "1");
|
||||
}
|
||||
if *sup_hide {
|
||||
val(w, "m:supHide", "1");
|
||||
}
|
||||
ctrl(w);
|
||||
w.close("m:naryPr");
|
||||
arg(w, "m:sub", sub, depth);
|
||||
arg(w, "m:sup", sup, depth);
|
||||
arg(w, "m:e", e, depth);
|
||||
w.close("m:nary");
|
||||
}
|
||||
MNode::Nary { .. } => write_nary(w, n, depth),
|
||||
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
|
||||
w.open("m:d", &[]);
|
||||
w.open("m:dPr", &[]);
|
||||
@@ -375,36 +410,7 @@ fn node(w: &mut W, n: &MNode, depth: usize) {
|
||||
}
|
||||
w.close("m:eqArr");
|
||||
}
|
||||
MNode::Matrix { rows, col_jc } => {
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
for c in 0..cols {
|
||||
let jc = match col_jc.get(c).copied().unwrap_or_default() {
|
||||
ColJc::Center => "center",
|
||||
ColJc::Left => "left",
|
||||
ColJc::Right => "right",
|
||||
};
|
||||
w.open("m:mc", &[]);
|
||||
w.open("m:mcPr", &[]);
|
||||
val(w, "m:count", "1");
|
||||
val(w, "m:mcJc", jc);
|
||||
w.close("m:mcPr");
|
||||
w.close("m:mc");
|
||||
}
|
||||
w.close("m:mcs");
|
||||
ctrl(w);
|
||||
w.close("m:mPr");
|
||||
for r in rows {
|
||||
w.open("m:mr", &[]);
|
||||
for c in 0..cols {
|
||||
arg(w, "m:e", r.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth);
|
||||
}
|
||||
w.close("m:mr");
|
||||
}
|
||||
w.close("m:m");
|
||||
}
|
||||
MNode::Matrix { .. } => write_matrix(w, n, depth),
|
||||
MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => {
|
||||
w.open("m:phant", &[]);
|
||||
w.open("m:phantPr", &[]);
|
||||
|
||||
+177
-155
@@ -3,7 +3,10 @@ use super::settings::{
|
||||
ANONYMOUS_INITIALS, annotation_author, app_xml, core_xml, note_id_base, notes_part, settings_xml, theme_xml, write_comment_ids,
|
||||
};
|
||||
use super::styles::{numbering_xml, styles_xml};
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, Blocks, CT_WML, Document, DocxError, Flavor, HashMap, MAX_VBA_RELATED, PartRels, VBA_PROJECT_PART, VBA_RELATED, W, Writer, embed,
|
||||
glossary, rt, settings_merge, watermark, xml,
|
||||
};
|
||||
|
||||
/// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name
|
||||
/// without the leading `/`, bytes).
|
||||
@@ -60,6 +63,8 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
let mut overrides: Vec<(String, String)> = Vec::new();
|
||||
let mut rels = PartRels::default();
|
||||
let mut root = PartRels::default();
|
||||
let ns = xml::body_ns();
|
||||
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
|
||||
// Comments get paragraph ids before any story is written.
|
||||
let mut comment_paras: Vec<(u32, String)> = Vec::new();
|
||||
@@ -73,41 +78,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
}
|
||||
}
|
||||
|
||||
// Body.
|
||||
let mut w = W::new();
|
||||
let ns = xml::body_ns();
|
||||
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
w.open("w:document", &ns_refs);
|
||||
let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media));
|
||||
if pic.is_some() || doc.settings.page_color.is_some() {
|
||||
let color = doc.settings.page_color.map(|c| c.hex());
|
||||
let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect();
|
||||
w.open("w:background", &attrs);
|
||||
if let Some(p) = pic
|
||||
&& let Some(file) = wr.media_files.get(&p.media)
|
||||
{
|
||||
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
wr.used_media.insert(p.media.clone());
|
||||
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
|
||||
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
|
||||
if !p.fill_type.is_empty() {
|
||||
fa.push(("type", &p.fill_type));
|
||||
}
|
||||
if !p.title.is_empty() {
|
||||
fa.push(("o:title", &p.title));
|
||||
}
|
||||
w.empty("v:fill", &fa);
|
||||
w.close("v:background");
|
||||
}
|
||||
w.close("w:background");
|
||||
}
|
||||
w.open("w:body", &[]);
|
||||
wr.blocks(&mut w, &doc.body, &mut rels, true, 0);
|
||||
let last = doc.last_section.clone();
|
||||
wr.sectpr(&mut w, &last, &mut rels);
|
||||
w.close("w:body");
|
||||
w.close("w:document");
|
||||
let body = w.into_bytes();
|
||||
let body = write_document_body(doc, &mut wr, &mut rels, &ns_refs);
|
||||
|
||||
// Headers and footers (discovered while writing sections).
|
||||
let mut done = 0;
|
||||
@@ -150,126 +121,9 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
wr.glossary(&mut entries, &mut overrides, &mut rels);
|
||||
}
|
||||
|
||||
// Notes (references were collected from the body and headers).
|
||||
for foot in [true, false] {
|
||||
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
|
||||
if !notes_part(doc, &wr, foot) {
|
||||
continue;
|
||||
}
|
||||
let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") };
|
||||
let mut prels = PartRels::default();
|
||||
let mut w = W::new();
|
||||
w.open(root, &ns_refs);
|
||||
// The separators and continuation notice: the document's own, else the defaults (no
|
||||
// notice). Word numbers them -1, 0 and 1.
|
||||
for (id, kind, own) in [
|
||||
("-1", "separator", &seps.separator),
|
||||
("0", "continuationSeparator", &seps.continuation_separator),
|
||||
("1", "continuationNotice", &seps.continuation_notice),
|
||||
] {
|
||||
match own {
|
||||
Some(blocks) => {
|
||||
w.open(tag, &[("w:type", kind), ("w:id", id)]);
|
||||
if blocks.is_empty() {
|
||||
w.empty("w:p", &[]);
|
||||
} else {
|
||||
wr.blocks(&mut w, blocks, &mut prels, false, 0);
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
None if kind != "continuationNotice" => {
|
||||
w.open(tag, &[("w:type", kind), ("w:id", id)]);
|
||||
w.open("w:p", &[]);
|
||||
w.open("w:pPr", &[]);
|
||||
w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]);
|
||||
w.close("w:pPr");
|
||||
w.open("w:r", &[]);
|
||||
w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]);
|
||||
w.close("w:r");
|
||||
w.close("w:p");
|
||||
w.close(tag);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let base = note_id_base(doc, foot);
|
||||
let mut k = 0;
|
||||
while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() {
|
||||
let nid = (k + base).to_string();
|
||||
k += 1;
|
||||
w.open(tag, &[("w:id", &nid)]);
|
||||
let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid);
|
||||
w.close(tag);
|
||||
}
|
||||
w.close(root);
|
||||
let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES };
|
||||
rels.add(kind, file, false);
|
||||
push_part(
|
||||
&mut entries,
|
||||
&mut overrides,
|
||||
&format!("word/{file}"),
|
||||
w.into_bytes(),
|
||||
&format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }),
|
||||
prels,
|
||||
);
|
||||
}
|
||||
write_notes(doc, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels);
|
||||
|
||||
// Comments.
|
||||
if !doc.comments.is_empty() {
|
||||
let mut prels = PartRels::default();
|
||||
let mut w = W::new();
|
||||
w.open("w:comments", &ns_refs);
|
||||
for (cid, c) in &doc.comments {
|
||||
let id = cid.to_string();
|
||||
let author = annotation_author(doc, &c.author);
|
||||
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
|
||||
if !c.date.is_empty() && !doc.settings.remove_date_time {
|
||||
a.push(("w:date", c.date.as_str()));
|
||||
}
|
||||
if doc.settings.remove_personal_info {
|
||||
a.push(("w:initials", ANONYMOUS_INITIALS));
|
||||
} else if !c.initials.is_empty() {
|
||||
a.push(("w:initials", c.initials.as_str()));
|
||||
}
|
||||
w.open("w:comment", &a);
|
||||
let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
wr.blocks(&mut w, &blocks, &mut prels, false, 0);
|
||||
w.close("w:comment");
|
||||
}
|
||||
w.close("w:comments");
|
||||
rels.add(rt::COMMENTS, "comments.xml", false);
|
||||
push_part(&mut entries, &mut overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels);
|
||||
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"w15:commentsEx",
|
||||
&[
|
||||
("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
|
||||
("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
|
||||
],
|
||||
);
|
||||
let pid_of = |cid: u32| comment_paras.iter().find(|(c, _)| *c == cid).map(|(_, p)| p.clone());
|
||||
for (cid, c) in &doc.comments {
|
||||
let Some(pid) = pid_of(*cid) else { continue };
|
||||
let done = if c.resolved { "1" } else { "0" };
|
||||
match c.parent.and_then(pid_of) {
|
||||
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]),
|
||||
None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]),
|
||||
}
|
||||
}
|
||||
w.close("w15:commentsEx");
|
||||
rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false);
|
||||
push_part(
|
||||
&mut entries,
|
||||
&mut overrides,
|
||||
"word/commentsExtended.xml",
|
||||
w.into_bytes(),
|
||||
&format!("{CT_WML}commentsExtended+xml"),
|
||||
PartRels::default(),
|
||||
);
|
||||
write_comment_ids(doc, &comment_paras, &mut entries, &mut overrides, &mut rels);
|
||||
}
|
||||
write_comments(doc, &comment_paras, &ns_refs, &mut wr, &mut entries, &mut overrides, &mut rels);
|
||||
|
||||
rels.add(rt::STYLES, "styles.xml", false);
|
||||
push_part(&mut entries, &mut overrides, "word/styles.xml", styles_xml(doc), &format!("{CT_WML}styles+xml"), PartRels::default());
|
||||
@@ -425,6 +279,174 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels, ns_refs: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open("w:document", ns_refs);
|
||||
let pic = doc.settings.page_picture.as_ref().filter(|p| wr.media_files.contains_key(&p.media) && doc.media.contains_key(&p.media));
|
||||
if pic.is_some() || doc.settings.page_color.is_some() {
|
||||
let color = doc.settings.page_color.map(|c| c.hex());
|
||||
let attrs: Vec<(&str, &str)> = color.iter().map(|c| ("w:color", c.as_str())).collect();
|
||||
w.open("w:background", &attrs);
|
||||
if let Some(p) = pic
|
||||
&& let Some(file) = wr.media_files.get(&p.media)
|
||||
{
|
||||
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
wr.used_media.insert(p.media.clone());
|
||||
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
|
||||
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
|
||||
if !p.fill_type.is_empty() {
|
||||
fa.push(("type", &p.fill_type));
|
||||
}
|
||||
if !p.title.is_empty() {
|
||||
fa.push(("o:title", &p.title));
|
||||
}
|
||||
w.empty("v:fill", &fa);
|
||||
w.close("v:background");
|
||||
}
|
||||
w.close("w:background");
|
||||
}
|
||||
w.open("w:body", &[]);
|
||||
wr.blocks(&mut w, &doc.body, rels, true, 0);
|
||||
let last = doc.last_section.clone();
|
||||
wr.sectpr(&mut w, &last, rels);
|
||||
w.close("w:body");
|
||||
w.close("w:document");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
fn write_notes(
|
||||
doc: &Document,
|
||||
ns_refs: &[(&str, &str)],
|
||||
wr: &mut Writer<'_>,
|
||||
entries: &mut Vec<(String, Vec<u8>)>,
|
||||
overrides: &mut Vec<(String, String)>,
|
||||
rels: &mut PartRels,
|
||||
) {
|
||||
for foot in [true, false] {
|
||||
let seps = if foot { &doc.footnote_separators } else { &doc.endnote_separators };
|
||||
if !notes_part(doc, wr, foot) {
|
||||
continue;
|
||||
}
|
||||
let (root, tag, file) = if foot { ("w:footnotes", "w:footnote", "footnotes.xml") } else { ("w:endnotes", "w:endnote", "endnotes.xml") };
|
||||
let mut prels = PartRels::default();
|
||||
let mut w = W::new();
|
||||
w.open(root, ns_refs);
|
||||
// The separators and continuation notice: the document's own, else the defaults (no
|
||||
// notice). Word numbers them -1, 0 and 1.
|
||||
for (id, kind, own) in [
|
||||
("-1", "separator", &seps.separator),
|
||||
("0", "continuationSeparator", &seps.continuation_separator),
|
||||
("1", "continuationNotice", &seps.continuation_notice),
|
||||
] {
|
||||
match own {
|
||||
Some(blocks) => {
|
||||
w.open(tag, &[("w:type", kind), ("w:id", id)]);
|
||||
if blocks.is_empty() {
|
||||
w.empty("w:p", &[]);
|
||||
} else {
|
||||
wr.blocks(&mut w, blocks, &mut prels, false, 0);
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
None if kind != "continuationNotice" => {
|
||||
w.open(tag, &[("w:type", kind), ("w:id", id)]);
|
||||
w.open("w:p", &[]);
|
||||
w.open("w:pPr", &[]);
|
||||
w.empty("w:spacing", &[("w:after", "0"), ("w:line", "240"), ("w:lineRule", "auto")]);
|
||||
w.close("w:pPr");
|
||||
w.open("w:r", &[]);
|
||||
w.empty(if kind == "separator" { "w:separator" } else { "w:continuationSeparator" }, &[]);
|
||||
w.close("w:r");
|
||||
w.close("w:p");
|
||||
w.close(tag);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let base = note_id_base(doc, foot);
|
||||
let mut k = 0;
|
||||
while let Some(pid) = (if foot { &wr.footnotes } else { &wr.endnotes }).get(k).copied() {
|
||||
let nid = (k + base).to_string();
|
||||
k += 1;
|
||||
w.open(tag, &[("w:id", &nid)]);
|
||||
let blocks: Blocks = doc.parts.get(&pid).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
wr.note_blocks(&mut w, &blocks, &mut prels, foot, pid);
|
||||
w.close(tag);
|
||||
}
|
||||
w.close(root);
|
||||
let kind = if foot { rt::FOOTNOTES } else { rt::ENDNOTES };
|
||||
rels.add(kind, file, false);
|
||||
push_part(
|
||||
entries,
|
||||
overrides,
|
||||
&format!("word/{file}"),
|
||||
w.into_bytes(),
|
||||
&format!("{CT_WML}{}+xml", if foot { "footnotes" } else { "endnotes" }),
|
||||
prels,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_comments(
|
||||
doc: &Document,
|
||||
comment_paras: &[(u32, String)],
|
||||
ns_refs: &[(&str, &str)],
|
||||
wr: &mut Writer<'_>,
|
||||
entries: &mut Vec<(String, Vec<u8>)>,
|
||||
overrides: &mut Vec<(String, String)>,
|
||||
rels: &mut PartRels,
|
||||
) {
|
||||
if !doc.comments.is_empty() {
|
||||
let mut prels = PartRels::default();
|
||||
let mut w = W::new();
|
||||
w.open("w:comments", ns_refs);
|
||||
for (cid, c) in &doc.comments {
|
||||
let id = cid.to_string();
|
||||
let author = annotation_author(doc, &c.author);
|
||||
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
|
||||
if !c.date.is_empty() && !doc.settings.remove_date_time {
|
||||
a.push(("w:date", c.date.as_str()));
|
||||
}
|
||||
if doc.settings.remove_personal_info {
|
||||
a.push(("w:initials", ANONYMOUS_INITIALS));
|
||||
} else if !c.initials.is_empty() {
|
||||
a.push(("w:initials", c.initials.as_str()));
|
||||
}
|
||||
w.open("w:comment", &a);
|
||||
let blocks: Blocks = doc.parts.get(&c.part).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
wr.blocks(&mut w, &blocks, &mut prels, false, 0);
|
||||
w.close("w:comment");
|
||||
}
|
||||
w.close("w:comments");
|
||||
rels.add(rt::COMMENTS, "comments.xml", false);
|
||||
push_part(entries, overrides, "word/comments.xml", w.into_bytes(), &format!("{CT_WML}comments+xml"), prels);
|
||||
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"w15:commentsEx",
|
||||
&[
|
||||
("xmlns:w15", "http://schemas.microsoft.com/office/word/2012/wordml"),
|
||||
("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"),
|
||||
],
|
||||
);
|
||||
// Assigned in BTreeMap comment-ID order above, including gaps for missing parts.
|
||||
let pid_of =
|
||||
|cid: u32| comment_paras.binary_search_by_key(&cid, |(c, _)| *c).ok().and_then(|i| comment_paras.get(i)).map(|(_, p)| p.as_str());
|
||||
for (cid, c) in &doc.comments {
|
||||
let Some(pid) = pid_of(*cid) else { continue };
|
||||
let done = if c.resolved { "1" } else { "0" };
|
||||
match c.parent.and_then(pid_of) {
|
||||
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:paraIdParent", parent), ("w15:done", done)]),
|
||||
None => w.empty("w15:commentEx", &[("w15:paraId", pid), ("w15:done", done)]),
|
||||
}
|
||||
}
|
||||
w.close("w15:commentsEx");
|
||||
rels.add(rt::COMMENTS_EX, "commentsExtended.xml", false);
|
||||
push_part(entries, overrides, "word/commentsExtended.xml", w.into_bytes(), &format!("{CT_WML}commentsExtended+xml"), PartRels::default());
|
||||
write_comment_ids(doc, comment_paras, entries, overrides, rels);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_part(
|
||||
entries: &mut Vec<(String, Vec<u8>)>,
|
||||
overrides: &mut Vec<(String, String)>,
|
||||
|
||||
@@ -53,7 +53,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
toggle(w, "w:emboss", c.emboss);
|
||||
toggle(w, "w:imprint", c.engrave);
|
||||
toggle(w, "w:noProof", c.no_proof);
|
||||
toggle(w, "w:snapToGrid", c.snap_to_grid);
|
||||
toggle(w, "w:vanish", c.hidden);
|
||||
toggle(w, "w:webHidden", c.web_hidden);
|
||||
match c.color {
|
||||
Some(TextColor::Auto) => w.val("w:color", "auto"),
|
||||
Some(TextColor::Rgb(rgb)) => w.val("w:color", &rgb.hex()),
|
||||
@@ -253,6 +255,40 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
toggle(w, "w:keepNext", p.keep_next);
|
||||
toggle(w, "w:keepLines", p.keep_lines);
|
||||
toggle(w, "w:pageBreakBefore", p.page_break_before);
|
||||
write_frame(w, p, framed);
|
||||
toggle(w, "w:widowControl", p.widow_control);
|
||||
if let Some(nr) = p.numbering {
|
||||
w.open("w:numPr", &[]);
|
||||
w.val("w:ilvl", &n(nr.level.min(8) as i64));
|
||||
w.val("w:numId", &n(nr.num as i64));
|
||||
if let Some(a) = num_change {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:numberingChange", &refs);
|
||||
}
|
||||
w.close("w:numPr");
|
||||
}
|
||||
toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers);
|
||||
if let Some(b) = &p.borders {
|
||||
borders(w, "w:pBdr", b, Some("w:between"), &[]);
|
||||
}
|
||||
if let Some(s) = p.shading {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]);
|
||||
}
|
||||
write_tabs(w, p);
|
||||
write_text_flow(w, p);
|
||||
write_spacing(w, p);
|
||||
write_indentation(w, p);
|
||||
toggle(w, "w:contextualSpacing", p.contextual_spacing);
|
||||
toggle(w, "w:mirrorIndents", p.mirror_indents);
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(l) = p.outline_level {
|
||||
w.val("w:outlineLvl", &n(l.min(9) as i64));
|
||||
}
|
||||
}
|
||||
|
||||
fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
|
||||
if framed && let Some(lines) = p.drop_cap.filter(|l| *l > 0) {
|
||||
// ECMA-376 §17.3.1.11: the drop cap's frame — dropped or in the margin, the lines it
|
||||
// spans and its distance from the text (`w:hSpace`, twips).
|
||||
@@ -295,24 +331,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
}
|
||||
w.empty("w:framePr", &a);
|
||||
}
|
||||
toggle(w, "w:widowControl", p.widow_control);
|
||||
if let Some(nr) = p.numbering {
|
||||
w.open("w:numPr", &[]);
|
||||
w.val("w:ilvl", &n(nr.level.min(8) as i64));
|
||||
w.val("w:numId", &n(nr.num as i64));
|
||||
if let Some(a) = num_change {
|
||||
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
w.empty("w:numberingChange", &refs);
|
||||
}
|
||||
w.close("w:numPr");
|
||||
}
|
||||
toggle(w, "w:suppressLineNumbers", p.suppress_line_numbers);
|
||||
if let Some(b) = &p.borders {
|
||||
borders(w, "w:pBdr", b, Some("w:between"), &[]);
|
||||
}
|
||||
if let Some(s) = p.shading {
|
||||
w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &s.hex())]);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_tabs(w: &mut W, p: &ParaProps) {
|
||||
if let Some(tabs) = &p.tabs
|
||||
&& !tabs.is_empty()
|
||||
{
|
||||
@@ -334,6 +355,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
}
|
||||
w.close("w:tabs");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_text_flow(w: &mut W, p: &ParaProps) {
|
||||
toggle(w, "w:suppressAutoHyphens", p.suppress_hyphens);
|
||||
toggle(w, "w:kinsoku", p.kinsoku);
|
||||
toggle(w, "w:wordWrap", p.word_wrap);
|
||||
@@ -343,6 +367,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
toggle(w, "w:snapToGrid", p.snap_to_grid);
|
||||
}
|
||||
|
||||
fn write_spacing(w: &mut W, p: &ParaProps) {
|
||||
if p.space_before.is_some()
|
||||
|| p.space_after.is_some()
|
||||
|| p.line_spacing.is_some()
|
||||
@@ -375,6 +402,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
}
|
||||
w.empty("w:spacing", &a);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_indentation(w: &mut W, p: &ParaProps) {
|
||||
let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars];
|
||||
if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) {
|
||||
let l = p.indent_left.map(twips);
|
||||
@@ -404,14 +434,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
}
|
||||
w.empty("w:ind", &a);
|
||||
}
|
||||
toggle(w, "w:contextualSpacing", p.contextual_spacing);
|
||||
toggle(w, "w:mirrorIndents", p.mirror_indents);
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(l) = p.outline_level {
|
||||
w.val("w:outlineLvl", &n(l.min(9) as i64));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn margins(w: &mut W, tag: &str, m: &[f32; 4]) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::parts::push_part;
|
||||
use super::*;
|
||||
use super::{CT_WML, Document, PartRels, W, Writer, math, rt, story, xml};
|
||||
|
||||
/// The file id of the first footnote or endnote: after the separators (-1, 0) and the
|
||||
/// continuation notice (1), when the document has one.
|
||||
@@ -102,22 +102,41 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp
|
||||
}
|
||||
}
|
||||
w.val("w:characterSpacingControl", "doNotCompress");
|
||||
write_note_settings(&mut w, s, s_notes, footnotes, endnotes);
|
||||
write_compatibility_settings(&mut w, doc);
|
||||
if let Some(m) = &s.math {
|
||||
math::math_pr(&mut w, m);
|
||||
}
|
||||
w.close("w:settings");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
fn write_note_settings(
|
||||
w: &mut W,
|
||||
settings: &wordcraft_doc::Settings,
|
||||
separators: (&wordcraft_doc::NoteSeparators, &wordcraft_doc::NoteSeparators),
|
||||
footnotes: bool,
|
||||
endnotes: bool,
|
||||
) {
|
||||
for (tag, fmt, pr, used, el) in [
|
||||
("w:footnotePr", s.footnote_format, &s.footnote_pr, footnotes, "w:footnote"),
|
||||
("w:endnotePr", s.endnote_format, &s.endnote_pr, endnotes, "w:endnote"),
|
||||
("w:footnotePr", settings.footnote_format, &settings.footnote_pr, footnotes, "w:footnote"),
|
||||
("w:endnotePr", settings.endnote_format, &settings.endnote_pr, endnotes, "w:endnote"),
|
||||
] {
|
||||
w.open(tag, &[]);
|
||||
story::note_pr_inner(&mut w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr });
|
||||
story::note_pr_inner(w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr });
|
||||
if used {
|
||||
w.empty(el, &[("w:id", "-1")]);
|
||||
w.empty(el, &[("w:id", "0")]);
|
||||
let seps = if el == "w:footnote" { &s_notes.0 } else { &s_notes.1 };
|
||||
let seps = if el == "w:footnote" { separators.0 } else { separators.1 };
|
||||
if seps.continuation_notice.is_some() {
|
||||
w.empty(el, &[("w:id", "1")]);
|
||||
}
|
||||
}
|
||||
w.close(tag);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_compatibility_settings(w: &mut W, doc: &Document) {
|
||||
w.open("w:compat", &[]);
|
||||
// CT_Compat: the older switches come before the `w:compatSetting`s.
|
||||
if doc.settings.do_not_expand_shift_return {
|
||||
@@ -126,17 +145,12 @@ pub(super) fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, temp
|
||||
if doc.settings.sum_paragraph_spacing {
|
||||
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
|
||||
}
|
||||
if s.allow_space_of_same_style_in_table {
|
||||
if doc.settings.allow_space_of_same_style_in_table {
|
||||
w.empty("w:allowSpaceOfSameStyleInTable", &[]);
|
||||
}
|
||||
let mode = doc.settings.compat_mode.clamp(11, 15).to_string();
|
||||
w.empty("w:compatSetting", &[("w:name", "compatibilityMode"), ("w:uri", "http://schemas.microsoft.com/office/word"), ("w:val", &mode)]);
|
||||
w.close("w:compat");
|
||||
if let Some(m) = &s.math {
|
||||
math::math_pr(&mut w, m);
|
||||
}
|
||||
w.close("w:settings");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Is `d` a usable `w16cid:durableId`: 1–8 hex digits, below 0x7FFFFFFF, not 0?
|
||||
@@ -158,6 +172,8 @@ pub(super) fn write_comment_ids(
|
||||
if comment_paras.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Keep the existing exact-string reservation rule, including comments without a written part.
|
||||
let reserved: std::collections::HashSet<&str> = doc.comments.values().filter_map(|c| c.durable_id.as_deref()).collect();
|
||||
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut next = 0x1000_0000u32;
|
||||
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
|
||||
@@ -169,7 +185,7 @@ pub(super) fn write_comment_ids(
|
||||
None => loop {
|
||||
next = next.saturating_add(1);
|
||||
let d = format!("{next:08X}");
|
||||
if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) {
|
||||
if !used.contains(&d) && !reserved.contains(d.as_str()) {
|
||||
break d;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::toc::written_toc_span;
|
||||
use super::*;
|
||||
use super::{Block, Blocks, CharProps, MAX_DEPTH, PartRels, Table, W, Writer, tblpr, tcpr, trpr, twips};
|
||||
|
||||
impl Writer<'_> {
|
||||
fn write_trailing_para(&mut self, w: &mut W, depth: usize) {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
use super::object::ink_name;
|
||||
use super::*;
|
||||
use super::{
|
||||
AltText, Anchor, Fill, Float, GradientFlip, GradientKind, InlineObject, PartRels, PathShape, Rgb, ShapeEffects, ShapeKind, W, Wrap, Writer, emu,
|
||||
n, rt,
|
||||
};
|
||||
|
||||
pub(super) struct DrawingInfo<'a> {
|
||||
pub float: &'a Float,
|
||||
pub size: (f32, f32),
|
||||
pub docpr: &'a str,
|
||||
pub name: &'a str,
|
||||
pub alt: &'a str,
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
@@ -198,28 +209,19 @@ impl Writer<'_> {
|
||||
w.close("wps:wsp");
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// A `w:drawing` holding `graphic` (an `a:graphic`) in a frame of the object's size and place.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn graphic_frame(
|
||||
&mut self,
|
||||
w: &mut W,
|
||||
float: &Float,
|
||||
(gw, gh): (f32, f32),
|
||||
docpr: &str,
|
||||
name: &str,
|
||||
alt: &str,
|
||||
graphic: &crate::xml::El,
|
||||
) {
|
||||
pub(super) fn graphic_frame(&mut self, w: &mut W, info: DrawingInfo<'_>, graphic: &crate::xml::El) {
|
||||
let inline = info.float.wrap == Wrap::Inline;
|
||||
w.open("w:drawing", &[]);
|
||||
self.drawing_open(w, float, gw, gh, docpr, name, alt);
|
||||
self.drawing_open(w, info);
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.raw(&graphic.to_xml());
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close(if inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
}
|
||||
|
||||
pub(super) fn drawing_open(&mut self, w: &mut W, float: &Float, iw: f32, ih: f32, docpr: &str, name: &str, alt: &str) {
|
||||
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
if float.wrap == Wrap::Inline {
|
||||
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
use super::toc::is_toc;
|
||||
use super::*;
|
||||
use super::{CharProps, W, Writer, rpr};
|
||||
|
||||
pub(super) struct FieldData<'a> {
|
||||
pub instr: &'a str,
|
||||
pub result: &'a str,
|
||||
pub locked: bool,
|
||||
pub dirty: bool,
|
||||
pub form: &'a str,
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
pub(super) fn field(&mut self, w: &mut W, instr: &str, result: &str, locked: bool, dirty: bool, form: &str, props: &CharProps) {
|
||||
pub(super) fn field(&mut self, w: &mut W, field: FieldData<'_>, props: &CharProps) {
|
||||
let FieldData { instr, result, locked, dirty, form } = field;
|
||||
if self.toc_hold_end && is_toc(instr) {
|
||||
// A TOC heading's field opens in the first entry and closes after the last (see `toc_span`).
|
||||
self.toc_hold_end = false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::drawing::{shape_name, spin_attrs, write_fill, xfrm_spin_only};
|
||||
use super::field::field_run;
|
||||
use super::drawing::{DrawingInfo, shape_name, spin_attrs, write_fill, xfrm_spin_only};
|
||||
use super::field::{FieldData, field_run};
|
||||
use super::section::comment_reference_style;
|
||||
use super::*;
|
||||
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
|
||||
|
||||
impl Writer<'_> {
|
||||
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
@@ -56,7 +56,9 @@ impl Writer<'_> {
|
||||
None => crate::write::math::write_equation(w, linear, *display, math, color),
|
||||
}
|
||||
}
|
||||
InlineObject::Field { instr, result, locked, dirty, form, .. } => self.field(w, instr, result, *locked, *dirty, form, props),
|
||||
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
|
||||
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
|
||||
}
|
||||
InlineObject::FieldStart { instr, locked, dirty } => {
|
||||
self.rev_open(w, props);
|
||||
self.field_start(w, instr, *locked, *dirty, "", props);
|
||||
@@ -111,223 +113,10 @@ impl Writer<'_> {
|
||||
}
|
||||
// Editable charts and SmartArt keep main's model writer; opaque graphics and native
|
||||
// ink keep their original source and related parts.
|
||||
InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => {
|
||||
let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink;
|
||||
let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
|
||||
(Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(),
|
||||
(None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(),
|
||||
// Ink in a group member's form (no drawing of its own) can't stand alone: left
|
||||
// out before its parts are queued, so no orphan part or relationship is written.
|
||||
(None, None, Some(src)) if ink && !standalone_ink(src) => {
|
||||
log::warn!("docx: group-member ink outside its group can't be written; it is left out");
|
||||
None
|
||||
}
|
||||
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
let Some(mut root) = root else { return };
|
||||
let direct_ink = ink && root.name == "w:contentPart";
|
||||
if ink && !direct_ink {
|
||||
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
|
||||
}
|
||||
// Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture.
|
||||
let alternate = (root.name == "mc:AlternateContent").then_some(&root);
|
||||
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
|
||||
let choice = alternate.and_then(|e| e.child("mc:Choice"));
|
||||
let native = choice.and_then(|e| e.find("a:graphic"));
|
||||
if alternate.is_some() && native.is_none() {
|
||||
return;
|
||||
}
|
||||
// A chart's picture gets the chart's current frame too; ink's VML preview is resized in place.
|
||||
let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
if direct_ink {
|
||||
w.raw(&root.to_xml());
|
||||
} else {
|
||||
if fallback.is_some() {
|
||||
let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() };
|
||||
w.open("mc:AlternateContent", &[]);
|
||||
w.open("mc:Choice", &[("Requires", requires)]);
|
||||
}
|
||||
let docpr = self.next_docpr();
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
let z = self.z;
|
||||
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, native.unwrap_or(&root));
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
match picture {
|
||||
Some(picture) => {
|
||||
// Both branches are one object: same id and stacking order.
|
||||
self.z = z;
|
||||
w.open("mc:Fallback", &[]);
|
||||
self.graphic_frame(w, float, (*gw, *gh), &docpr, &name, alt, picture);
|
||||
w.close("mc:Fallback");
|
||||
}
|
||||
None => w.raw(&fallback.to_xml()),
|
||||
}
|
||||
w.close("mc:AlternateContent");
|
||||
}
|
||||
}
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } => {
|
||||
// An OLE object read from a file: the object itself, at its current size.
|
||||
if let Some(src) = ole.as_deref()
|
||||
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
|
||||
{
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.raw(&obj);
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.media_files.get(media).cloned() else { return };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Picture {docpr}");
|
||||
self.drawing_open(w, float, *iw, *ih, &docpr, &name, alt);
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
self.pic(w, o, &file, (0.0, 0.0), rels);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.shape_docpr(extra.id);
|
||||
// Ink is told apart by its name (and its pen), which reading looks for.
|
||||
let name = shape_name(*kind, freeform.as_deref(), &docpr);
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
let mut float = float.clone();
|
||||
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
|
||||
*e = wordcraft_geom::finite(*e).max(fx);
|
||||
}
|
||||
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
|
||||
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
|
||||
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner)
|
||||
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
|
||||
.map(std::sync::Arc::new);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, float, *sw, *sh, &docpr, &name, o.alt_text());
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } => {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
|
||||
self.drawing_open(w, float, *gw, *gh, &docpr, &name, o.alt_text());
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
let outer = match canvas {
|
||||
Some(c) => {
|
||||
// A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then
|
||||
// its members, at offsets in the canvas's own (unscaled) space.
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
|
||||
w.open("wpc:wpc", &[]);
|
||||
w.open("wpc:bg", &[]);
|
||||
write_fill(w, c.fill.as_ref());
|
||||
w.close("wpc:bg");
|
||||
w.open("wpc:whole", &[]);
|
||||
match c.stroke {
|
||||
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
|
||||
None => w.open("a:ln", &[]),
|
||||
}
|
||||
write_fill(w, c.stroke.map(Fill::solid).as_ref());
|
||||
w.close("a:ln");
|
||||
w.close("wpc:whole");
|
||||
"wpc:wpc"
|
||||
}
|
||||
None => {
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
|
||||
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
|
||||
// maps onto the group's `a:off`/`a:ext`.
|
||||
w.open("wpg:wgp", &[]);
|
||||
w.empty("wpg:cNvGrpSpPr", &[]);
|
||||
w.open("wpg:grpSpPr", &[]);
|
||||
w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
|
||||
w.empty("a:off", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
|
||||
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
|
||||
w.close("a:xfrm");
|
||||
w.close("wpg:grpSpPr");
|
||||
"wpg:wgp"
|
||||
}
|
||||
};
|
||||
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
|
||||
let at = (c.x, c.y);
|
||||
match &c.obj {
|
||||
InlineObject::Image { media, .. } => {
|
||||
if let Some(file) = self.media_files.get(media).cloned() {
|
||||
self.pic(w, &c.obj, &file, at, rels);
|
||||
}
|
||||
}
|
||||
InlineObject::Shape { extra, .. } => {
|
||||
let id = self.shape_docpr(extra.id);
|
||||
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
|
||||
}
|
||||
// Native ink kept from the file, with its fallback, at the member's place.
|
||||
InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => {
|
||||
if let Some(src) = graphic.source.as_deref()
|
||||
&& let Some(mut root) = self.embedded_tree(src, rels, None)
|
||||
{
|
||||
Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0);
|
||||
w.raw(&root.to_xml());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() {
|
||||
// The canvas schema has no transform of its own: keep its turn in our
|
||||
// extension (Word draws it unturned, as it can't turn a canvas).
|
||||
w.open("wpc:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]);
|
||||
xfrm_spin_only(w, float.spin());
|
||||
w.close("a:ext");
|
||||
w.close("wpc:extLst");
|
||||
}
|
||||
w.close(outer);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth),
|
||||
InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth),
|
||||
InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth),
|
||||
InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth),
|
||||
InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => {
|
||||
// ECMA-376 §17.3.3.25: a run holding w:ruby (properties, ruby text, base text).
|
||||
let t = if self.is_del(props) { "w:delText" } else { "w:t" };
|
||||
@@ -385,6 +174,231 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_graphic(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
|
||||
let InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } = o else { return };
|
||||
let ink = graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink;
|
||||
let root = match (graphic.chart.as_deref(), graphic.smart_art.as_deref(), graphic.source.as_deref()) {
|
||||
(Some(spec), _, _) => crate::xml::parse(self.chart_graphic(spec, rels).as_bytes()).ok(),
|
||||
(None, Some(spec), _) => crate::xml::parse(self.smart_art_graphic(spec, *gw, *gh, rels).as_bytes()).ok(),
|
||||
// Ink in a group member's form (no drawing of its own) can't stand alone: left
|
||||
// out before its parts are queued, so no orphan part or relationship is written.
|
||||
(None, None, Some(src)) if ink && !standalone_ink(src) => {
|
||||
log::warn!("docx: group-member ink outside its group can't be written; it is left out");
|
||||
None
|
||||
}
|
||||
(None, None, Some(src)) => self.embedded_tree(src, rels, ink.then_some((*gw, *gh, float, alt.as_str()))),
|
||||
(None, None, None) => None,
|
||||
};
|
||||
let Some(mut root) = root else { return };
|
||||
let direct_ink = ink && root.name == "w:contentPart";
|
||||
if ink && !direct_ink {
|
||||
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
|
||||
}
|
||||
// Native ink, or an Office 2016 chart (`cx:chart`) with its fallback picture.
|
||||
let alternate = (root.name == "mc:AlternateContent").then_some(&root);
|
||||
let fallback = alternate.and_then(|e| e.child("mc:Fallback"));
|
||||
let choice = alternate.and_then(|e| e.child("mc:Choice"));
|
||||
let native = choice.and_then(|e| e.find("a:graphic"));
|
||||
if alternate.is_some() && native.is_none() {
|
||||
return;
|
||||
}
|
||||
// A chart's picture gets the chart's current frame too; ink's VML preview is resized in place.
|
||||
let picture = if ink { None } else { fallback.and_then(|e| e.find("a:graphic")) };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
if direct_ink {
|
||||
w.raw(&root.to_xml());
|
||||
} else {
|
||||
if fallback.is_some() {
|
||||
let requires = if ink { "wpi" } else { choice.and_then(|c| c.attr("Requires")).unwrap_or_default() };
|
||||
w.open("mc:AlternateContent", &[]);
|
||||
w.open("mc:Choice", &[("Requires", requires)]);
|
||||
}
|
||||
let docpr = self.next_docpr();
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
let z = self.z;
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
match picture {
|
||||
Some(picture) => {
|
||||
// Both branches are one object: same id and stacking order.
|
||||
self.z = z;
|
||||
w.open("mc:Fallback", &[]);
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
|
||||
w.close("mc:Fallback");
|
||||
}
|
||||
None => w.raw(&fallback.to_xml()),
|
||||
}
|
||||
w.close("mc:AlternateContent");
|
||||
}
|
||||
}
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
||||
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
|
||||
// An OLE object read from a file: the object itself, at its current size.
|
||||
if let Some(src) = ole.as_deref()
|
||||
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
|
||||
{
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.raw(&obj);
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.media_files.get(media).cloned() else { return };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("Picture {docpr}");
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
self.pic(w, o, &file, (0.0, 0.0), rels);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
||||
fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.shape_docpr(extra.id);
|
||||
// Ink is told apart by its name (and its pen), which reading looks for.
|
||||
let name = shape_name(*kind, freeform.as_deref(), &docpr);
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
let mut float = float.clone();
|
||||
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
|
||||
*e = wordcraft_geom::finite(*e).max(fx);
|
||||
}
|
||||
// Tight and Through wrapping need a polygon: the shape's outline unless it has one.
|
||||
if matches!(float.wrap, Wrap::Tight | Wrap::Through) && float.wrap_polygon.is_none() {
|
||||
float.wrap_polygon = wordcraft_doc::wrap::shape_outline(*kind, *sw, *sh, extra.corner)
|
||||
.and_then(|pts| wordcraft_doc::wrap::WrapPolygon::from_unit(false, &pts))
|
||||
.map(std::sync::Arc::new);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
self.wsp(w, o, None, (0.0, 0.0), rels, depth);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
|
||||
fn write_group(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
|
||||
let InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } = o else { return };
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
let outer = match canvas {
|
||||
Some(c) => {
|
||||
// A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then
|
||||
// its members, at offsets in the canvas's own (unscaled) space.
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
|
||||
w.open("wpc:wpc", &[]);
|
||||
w.open("wpc:bg", &[]);
|
||||
write_fill(w, c.fill.as_ref());
|
||||
w.close("wpc:bg");
|
||||
w.open("wpc:whole", &[]);
|
||||
match c.stroke {
|
||||
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
|
||||
None => w.open("a:ln", &[]),
|
||||
}
|
||||
write_fill(w, c.stroke.map(Fill::solid).as_ref());
|
||||
w.close("a:ln");
|
||||
w.close("wpc:whole");
|
||||
"wpc:wpc"
|
||||
}
|
||||
None => {
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]);
|
||||
// ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt`
|
||||
// maps onto the group's `a:off`/`a:ext`.
|
||||
w.open("wpg:wgp", &[]);
|
||||
w.empty("wpg:cNvGrpSpPr", &[]);
|
||||
w.open("wpg:grpSpPr", &[]);
|
||||
w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
|
||||
w.empty("a:off", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]);
|
||||
w.empty("a:chOff", &[("x", "0"), ("y", "0")]);
|
||||
w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]);
|
||||
w.close("a:xfrm");
|
||||
w.close("wpg:grpSpPr");
|
||||
"wpg:wgp"
|
||||
}
|
||||
};
|
||||
for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) {
|
||||
let at = (c.x, c.y);
|
||||
match &c.obj {
|
||||
InlineObject::Image { media, .. } => {
|
||||
if let Some(file) = self.media_files.get(media).cloned() {
|
||||
self.pic(w, &c.obj, &file, at, rels);
|
||||
}
|
||||
}
|
||||
InlineObject::Shape { extra, .. } => {
|
||||
let id = self.shape_docpr(extra.id);
|
||||
self.wsp(w, &c.obj, Some(&id), at, rels, depth);
|
||||
}
|
||||
// Native ink kept from the file, with its fallback, at the member's place.
|
||||
InlineObject::Graphic { w: iw, h: ih, float, graphic, .. } if graphic.kind == wordcraft_doc::graphic::GraphicKind::Ink => {
|
||||
if let Some(src) = graphic.source.as_deref()
|
||||
&& let Some(mut root) = self.embedded_tree(src, rels, None)
|
||||
{
|
||||
Self::place_member(&mut root, [c.x, c.y, *iw, *ih], float, 0);
|
||||
w.raw(&root.to_xml());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() {
|
||||
// The canvas schema has no transform of its own: keep its turn in our
|
||||
// extension (Word draws it unturned, as it can't turn a canvas).
|
||||
w.open("wpc:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]);
|
||||
xfrm_spin_only(w, float.spin());
|
||||
w.close("a:ext");
|
||||
w.close("wpc:extLst");
|
||||
}
|
||||
w.close(outer);
|
||||
w.close("a:graphicData");
|
||||
w.close("a:graphic");
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
ChangeAttrs, CharProps, InlineObject, Paragraph, PartRels, Piece, PropChange, RevisionKind, W, Writer, open_change, ppr_inner, rpr, rpr_inner, rt,
|
||||
};
|
||||
|
||||
impl Writer<'_> {
|
||||
pub(super) fn para(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{LineNumberRestart, NoteProps, PartRels, SectionProps, SectionStart, W, Writer, borders, n, open_change, twips};
|
||||
|
||||
impl Writer<'_> {
|
||||
/// The section's headers, plus the header holding only the watermark in each slot the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Blocks, InlineObject, W, Writer};
|
||||
|
||||
impl Writer<'_> {
|
||||
pub(super) fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Document, Level, LevelSuffix, ParaProps, Style, StyleKind, TablePart, VAlign, W, props, xml};
|
||||
|
||||
/// Built-in names Word keeps in lower case in styles.xml.
|
||||
fn file_style_name(name: &str) -> String {
|
||||
|
||||
@@ -155,6 +155,38 @@ fn zotero_fields_and_prefs_round_trip() {
|
||||
assert_eq!(read(&write(&d2).unwrap()).unwrap().custom_props, d.custom_props);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_property_bookmark_links_survive_saves_and_value_edits() {
|
||||
let props = r#"<c:Properties xmlns:c="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:v="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Linked" linkTarget="OriginalBookmark"><v:lpwstr>Original value</v:lpwstr></c:property>
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="Raw" linkTarget="RawBookmark"><v:vector size="1" baseType="lpwstr"><v:lpwstr>Ω</v:lpwstr></v:vector></c:property>
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="Unlinked"><v:i4>17</v:i4></c:property>
|
||||
</c:Properties>"#;
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:bookmarkStart w:id="1" w:name="OriginalBookmark"/>{}<w:bookmarkEnd w:id="1"/></w:p><w:p><w:bookmarkStart w:id="2" w:name="RawBookmark"/>{}<w:bookmarkEnd w:id="2"/></w:p></w:body></w:document>"#,
|
||||
t("Original value"),
|
||||
t("Ω")
|
||||
);
|
||||
let bytes = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("docProps/custom.xml", props.as_bytes())]);
|
||||
let mut d = read(&bytes).unwrap();
|
||||
for value in ["Original value", "Edited value"] {
|
||||
d.set_custom_prop("linked", value);
|
||||
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();
|
||||
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");
|
||||
d = read(&out).unwrap();
|
||||
assert_eq!(d.custom_props, before);
|
||||
assert_eq!(d.custom_prop("Linked"), Some(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_and_resultless_addin_fields() {
|
||||
// A field left open at the end of the document, and one with no result at all.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Saving large comment threads must not search all comments for each emitted ID.
|
||||
|
||||
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
|
||||
|
||||
fn comments(n: u32, imported: bool) -> Document {
|
||||
let mut d = Document::from_text("Original comment save fixture.");
|
||||
for i in 0..n {
|
||||
// Sparse IDs and replies to a later comment also exercise paragraph-ID lookup order.
|
||||
let id = i * 3 + 7;
|
||||
d.parts.insert(id, Part { kind: PartKind::Comment, blocks: vec![para_block(Paragraph::new())], ..Default::default() });
|
||||
d.comments.insert(
|
||||
id,
|
||||
Comment { part: id, parent: (i + 1 < n).then_some(id + 3), durable_id: imported.then(|| format!("{:08X}", i + 1)), ..Default::default() },
|
||||
);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn cpu_cost(mut f: impl FnMut()) -> f64 {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let (wall, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => (b - a) as f64 * 1e-9,
|
||||
_ => wall.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_comments_save_without_quadratic_id_searches() {
|
||||
for imported in [true, false] {
|
||||
let small = comments(4_000, imported);
|
||||
let big = comments(32_000, imported);
|
||||
let save = |d: &Document| drop(std::hint::black_box(wordcraft_docx::write(d).unwrap()));
|
||||
save(&small);
|
||||
save(&big);
|
||||
let mut ratios = Vec::new();
|
||||
// Alternating inputs and thread CPU time leave scheduler delays out on Linux. Elsewhere
|
||||
// use elapsed time; retry ratios far from linear before declaring a regression.
|
||||
for _ in 0..3 {
|
||||
let (mut s, mut b) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
s = s.min(cpu_cost(|| (0..8).for_each(|_| save(&small))) / 8.0);
|
||||
b = b.min(cpu_cost(|| save(&big)));
|
||||
}
|
||||
let ratio = b / s.max(1e-9);
|
||||
eprintln!("comments imported={imported}: {s:.4}s -> {b:.4}s, ratio {ratio:.2}");
|
||||
if ratio < 24.0 {
|
||||
break;
|
||||
}
|
||||
ratios.push(ratio);
|
||||
}
|
||||
assert!(ratios.len() < 3, "8x comments took {ratios:?}x CPU: repeated ID searches");
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,13 @@ fn by_id(d: &Document, id: i64) -> ContentControl {
|
||||
#[test]
|
||||
fn every_kind_reads_with_its_properties() {
|
||||
let d = read(&form()).unwrap();
|
||||
let intro = controls(&d).into_iter().find(|c| c.title == "Intro").unwrap();
|
||||
assert_intro_control(&d);
|
||||
assert_control_kinds(&d);
|
||||
assert_table_controls(&d);
|
||||
}
|
||||
|
||||
fn assert_intro_control(d: &Document) {
|
||||
let intro = controls(d).into_iter().find(|c| c.title == "Intro").unwrap();
|
||||
assert!(intro.block && intro.showing_placeholder);
|
||||
assert_eq!((intro.tag.as_str(), intro.id, intro.lock), ("intro", Some(-1201), ControlLock::SdtLocked));
|
||||
assert_eq!(intro.placeholder, "DefaultPlaceholder_1");
|
||||
@@ -119,13 +125,15 @@ fn every_kind_reads_with_its_properties() {
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
let r = ranges.iter().find(|r| r.control.title == "Intro").unwrap();
|
||||
assert_eq!(d.control_text(r), "Write the introduction.\nSecond paragraph");
|
||||
}
|
||||
|
||||
assert_eq!(by_id(&d, 7).kind, ControlKind::Text { multi_line: true });
|
||||
assert!(!by_id(&d, 7).block);
|
||||
fn assert_control_kinds(d: &Document) {
|
||||
assert_eq!(by_id(d, 7).kind, ControlKind::Text { multi_line: true });
|
||||
assert!(!by_id(d, 7).block);
|
||||
assert!(
|
||||
matches!(by_id(&d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola")
|
||||
matches!(by_id(d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola")
|
||||
);
|
||||
let dd = by_id(&d, 9);
|
||||
let dd = by_id(d, 9);
|
||||
assert_eq!(dd.lock, ControlLock::ContentLocked);
|
||||
assert_eq!(
|
||||
dd.kind,
|
||||
@@ -135,14 +143,17 @@ fn every_kind_reads_with_its_properties() {
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
matches!(by_id(&d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy")
|
||||
matches!(by_id(d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy")
|
||||
);
|
||||
assert_eq!(by_id(&d, 10).extra.len(), 1, "data binding kept");
|
||||
assert_eq!(by_id(&d, 12).kind, ControlKind::RichText);
|
||||
assert!(matches!(by_id(&d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages"));
|
||||
assert!(matches!(by_id(&d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items"));
|
||||
assert_eq!(by_id(&d, 21).kind, ControlKind::RepeatingSectionItem);
|
||||
assert_eq!(by_id(&d, 23).kind, ControlKind::Picture);
|
||||
assert_eq!(by_id(d, 10).extra.len(), 1, "data binding kept");
|
||||
assert_eq!(by_id(d, 12).kind, ControlKind::RichText);
|
||||
assert!(matches!(by_id(d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages"));
|
||||
assert!(matches!(by_id(d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items"));
|
||||
assert_eq!(by_id(d, 21).kind, ControlKind::RepeatingSectionItem);
|
||||
assert_eq!(by_id(d, 23).kind, ControlKind::Picture);
|
||||
}
|
||||
|
||||
fn assert_table_controls(d: &Document) {
|
||||
// Row-level controls sit on the rows, the cell-level one on its cell, the table's on the table.
|
||||
let tables: Vec<_> = d.body.iter().filter_map(|b| b.as_table()).collect();
|
||||
assert_eq!(tables.len(), 2);
|
||||
|
||||
@@ -114,6 +114,68 @@ fn paragraph_default_presence_survives_repeated_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_frame_keeps_normal_flow() {
|
||||
const ROUND_TRIPS: usize = 2;
|
||||
let zero = r#"<w:framePr w:w="0" w:h="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/>"#;
|
||||
let sized = r#"<w:framePr w:w="2400" w:h="1200" w:x="1440" w:y="2880" w:hAnchor="page" w:vAnchor="page" w:hRule="exact"/>"#;
|
||||
for frame in [zero, sized] {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr>{frame}<w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>
|
||||
<w:style w:type="paragraph" w:styleId="Framed"><w:name w:val="Framed"/><w:pPr>{sized}</w:pPr></w:style></w:styles>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Header alpha</w:t></w:r></w:p><w:p><w:r><w:t>Header beta</w:t></w:r></w:p></w:hdr>"#);
|
||||
let footer = format!(r#"<w:ftr {W_NS}><w:p><w:r><w:t>Footer delta</w:t></w:r></w:p></w:ftr>"#);
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Body one</w:t></w:r></w:p>
|
||||
<w:p><w:pPr>{sized}</w:pPr><w:r><w:t>Direct frame</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Styled frame</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Framed"/>{zero}</w:pPr><w:r><w:t>Frame reset</w:t></w:r></w:p>
|
||||
<w:sectPr><w:headerReference w:type="default" r:id="header"/><w:footerReference w:type="default" r:id="footer"/></w:sectPr>"#
|
||||
);
|
||||
let mut document = wordcraft_docx::read(&docx(
|
||||
&body,
|
||||
&[("styles", "styles", "styles.xml"), ("header", "header", "header1.xml"), ("footer", "footer", "footer1.xml")],
|
||||
&[("word/styles.xml", &styles), ("word/header1.xml", &header), ("word/footer1.xml", &footer)],
|
||||
))
|
||||
.unwrap();
|
||||
for pass in 0..=ROUND_TRIPS {
|
||||
let body = paras(&document);
|
||||
let resolved = document.styles.resolve_para(&body[0].props);
|
||||
assert!(resolved.frame.is_none(), "document defaults never create a frame");
|
||||
assert!(document.styles.default_para.frame.is_none());
|
||||
let reset = body[3].props.frame.as_ref().unwrap();
|
||||
assert_eq!((reset.w, reset.h), (Some(0.0), Some(0.0)), "the direct reset survives saving");
|
||||
assert_eq!(resolved.space_after, 8.0);
|
||||
assert!(document.styles.resolve_para(&body[1].props).frame.is_some());
|
||||
assert!(document.styles.resolve_para(&body[2].props).frame.is_some());
|
||||
assert!(document.styles.resolve_para(&body[3].props).frame.is_none());
|
||||
for id in [document.last_section.headers.default, document.last_section.footers.default] {
|
||||
for block in &document.parts.get(&id.unwrap()).unwrap().blocks {
|
||||
let paragraph = block.as_para().unwrap();
|
||||
assert!(document.styles.resolve_para(¶graph.props).frame.is_none(), "defaults do not frame headers or footers");
|
||||
}
|
||||
}
|
||||
if pass == ROUND_TRIPS {
|
||||
break;
|
||||
}
|
||||
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_zero_side_is_automatic() {
|
||||
for (attrs, width, height) in
|
||||
[(r#"w:w="0" w:h="1200""#, None, Some(60.0)), (r#"w:w="2400" w:h="0" w:hRule="exact""#, Some(120.0), None), ("", None, None)]
|
||||
{
|
||||
let document = read_body(&format!(r#"<w:p><w:pPr><w:framePr {attrs}/></w:pPr><w:r><w:t>Original automatic frame</w:t></w:r></w:p>"#));
|
||||
let paragraph = paras(&document)[0];
|
||||
let frame = document.styles.resolve_para(¶graph.props).frame.unwrap();
|
||||
assert_eq!((frame.w, frame.h), (width, height));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_paragraph_defaults_can_be_edited() {
|
||||
let styles =
|
||||
@@ -495,6 +557,12 @@ fn styles_numbering_settings_notes_comments_from_parts() {
|
||||
("docProps/core.xml", core.as_bytes()),
|
||||
]);
|
||||
let d = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_imported_styles(&d);
|
||||
assert_imported_numbering_and_settings(&d);
|
||||
assert_imported_body_and_metadata(&d);
|
||||
}
|
||||
|
||||
fn assert_imported_styles(d: &Document) {
|
||||
// Styles: localized default id renamed to Normal; display names mapped.
|
||||
let h = d.styles.get("1").unwrap();
|
||||
assert_eq!(h.name, "Heading 1");
|
||||
@@ -511,6 +579,9 @@ fn styles_numbering_settings_notes_comments_from_parts() {
|
||||
assert_eq!(g.header_chr.bold, Some(true));
|
||||
assert_eq!(d.settings.theme_colors[4], wordcraft_doc::Rgb(0x44, 0x72, 0xC4));
|
||||
assert_eq!(d.settings.major_font, "Major Face");
|
||||
}
|
||||
|
||||
fn assert_imported_numbering_and_settings(d: &Document) {
|
||||
// Numbering.
|
||||
let lv = d.numbering.level(2, 0).unwrap();
|
||||
assert_eq!((lv.start, lv.text.as_str(), lv.indent, lv.hanging, lv.align), (3, "%1)", 36.0, 18.0, Align::Right));
|
||||
@@ -525,8 +596,11 @@ fn styles_numbering_settings_notes_comments_from_parts() {
|
||||
// A hostile start is clamped, an unknown position left out.
|
||||
let fp = d.settings.footnote_pr;
|
||||
assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage)));
|
||||
}
|
||||
|
||||
fn assert_imported_body_and_metadata(d: &Document) {
|
||||
// Body.
|
||||
let p = paras(&d);
|
||||
let p = paras(d);
|
||||
assert_eq!(p[0].props.style.as_deref(), Some("1"));
|
||||
assert_eq!(p[0].props.numbering, Some(wordcraft_doc::props::NumRef { num: 2, level: 1 }));
|
||||
assert_eq!(p[0].props_of_char(4).font.as_deref(), Some("Major Face"));
|
||||
|
||||
@@ -509,3 +509,53 @@ fn hostile_glossaries_never_panic() {
|
||||
let doc = read(two);
|
||||
assert_eq!(doc.glossary.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_save_escapes_the_attribute_values_it_rewrites() {
|
||||
// A value read from single quotes holds a bare `"`; the privacy rewrite wrote it back inside
|
||||
// double quotes unescaped, which is not XML (review of #3872).
|
||||
let quoted = r#"<w:docPart><w:docPartPr><w:name w:val='Say "hi" & <go>'/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></w:p></w:docPartBody></w:docPart>"#;
|
||||
let bytes = package(
|
||||
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
|
||||
&[
|
||||
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
|
||||
("word/glossary/document.xml", glossary_xml(quoted)),
|
||||
],
|
||||
);
|
||||
let doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
let name = doc.glossary.first().map(|e| e.name.clone()).expect("an entry");
|
||||
assert_eq!(name, r#"Say "hi" & <go>"#);
|
||||
let mut private = doc.clone();
|
||||
private.settings.remove_personal_info = true;
|
||||
let saved = wordcraft_docx::write(&private).unwrap();
|
||||
let files = unzip(&saved);
|
||||
let g = text(&files, &glossary_target(&files).expect("a glossary"));
|
||||
assert!(!g.contains("Quill Ramsey"), "{g}");
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(again.glossary.first().map(|e| e.name.as_str()), Some(name.as_str()), "{g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
|
||||
// ` ` and `	` are a newline and a tab only as references; written as the raw
|
||||
// characters they read back as spaces (review of #3872).
|
||||
let entry = r#"<w:docPart><w:docPartPr><w:name w:val="Clause"/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category><w:description w:val="line1 line2	end"/></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></w:p></w:docPartBody></w:docPart>"#;
|
||||
let bytes = package(
|
||||
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
|
||||
&[
|
||||
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
|
||||
("word/glossary/document.xml", glossary_xml(entry)),
|
||||
],
|
||||
);
|
||||
let doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
let before = doc.glossary.first().map(|e| e.description.clone()).expect("an entry");
|
||||
assert_eq!(before, "line1\nline2\tend");
|
||||
let mut private = doc.clone();
|
||||
private.settings.remove_personal_info = true;
|
||||
let saved = wordcraft_docx::write(&private).unwrap();
|
||||
let files = unzip(&saved);
|
||||
let g = text(&files, &glossary_target(&files).expect("a glossary"));
|
||||
assert!(!g.contains("Quill Ramsey") && g.contains(r#"w:val="line1 line2	end""#), "{g}");
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! #4696: Word's built-in heading styles have their outline level even when the file's
|
||||
//! `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";
|
||||
|
||||
fn package(styles: &str, 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 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()));
|
||||
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.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn para(style: &str, text: &str) -> String {
|
||||
format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#)
|
||||
}
|
||||
|
||||
fn levels(d: &wordcraft_doc::Document) -> Vec<Option<u8>> {
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() {
|
||||
let styles = r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Titolo2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading9"><w:name w:val="Heading 9"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Below"><w:name w:val="Below heading 2"/><w:basedOn w:val="Titolo2"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="1" w:styleId="MyHead"><w:name w:val="My heading 3"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading10"><w:name w:val="heading 10"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="on" w:styleId="Fake4"><w:name w:val="heading 4"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:pPr><w:outlineLvl w:val="5"/></w:pPr></w:style>"#;
|
||||
let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"]
|
||||
.iter()
|
||||
.map(|s| para(s, "x"))
|
||||
.collect::<String>();
|
||||
let d = wordcraft_docx::read(&package(styles, &body)).unwrap();
|
||||
// Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style,
|
||||
// "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a
|
||||
// level the file gives is kept.
|
||||
let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None];
|
||||
assert_eq!(levels(&d), want);
|
||||
// The level survives a save and reopen.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(levels(&again), want);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod bidi_runs;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
mod comment_save_cost;
|
||||
mod complex_script_inheritance;
|
||||
mod content_control_save_cost;
|
||||
mod content_controls;
|
||||
@@ -13,14 +14,17 @@ mod east_asian_lang;
|
||||
mod east_asian_layout;
|
||||
mod fixtures;
|
||||
mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
mod row_property_exceptions;
|
||||
mod run_props;
|
||||
mod section_form_protection;
|
||||
mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Comments anchored inside footnotes and endnotes keep their ranges on read. All markup here is
|
||||
//! synthetic, written from ECMA-376 §17.13.4 and §17.11.
|
||||
|
||||
use wordcraft_doc::para::{NoteKind, OBJ};
|
||||
use wordcraft_doc::{Document, InlineObject};
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
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>"#;
|
||||
const DOC_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
|
||||
|
||||
fn note(tag: &str, mark: &str, comment: u32, text: &str) -> String {
|
||||
format!(
|
||||
r#"<w:{tag} w:id="1"><w:p><w:r><w:{mark}/></w:r><w:commentRangeStart w:id="{comment}"/><w:r><w:t xml:space="preserve"> {text}</w:t></w:r><w:commentRangeEnd w:id="{comment}"/><w:r><w:commentReference w:id="{comment}"/></w:r></w:p></w:{tag}>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn read_doc() -> Document {
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let footnotes = format!(r#"<w:footnotes {W_NS}>{}</w:footnotes>"#, note("footnote", "footnoteRef", 7, "Foot."));
|
||||
let endnotes = format!(r#"<w:endnotes {W_NS}>{}</w:endnotes>"#, note("endnote", "endnoteRef", 8, "End."));
|
||||
let comments = format!(
|
||||
r#"<w:comments {W_NS}><w:comment w:id="7" w:author="Ann" w:initials="A"><w:p><w:r><w:t>On the footnote</w:t></w:r></w:p></w:comment><w:comment w:id="8" w:author="Bob" w:initials="B"><w:p><w:r><w:t>On the endnote</w:t></w:r></w:p></w:comment></w:comments>"#
|
||||
);
|
||||
let bytes = zip(&[
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", DOC_RELS.as_bytes()),
|
||||
("word/footnotes.xml", footnotes.as_bytes()),
|
||||
("word/endnotes.xml", endnotes.as_bytes()),
|
||||
("word/comments.xml", comments.as_bytes()),
|
||||
]);
|
||||
wordcraft_docx::read(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// comments.xml has to be read before the notes: a note's `w:commentRangeStart` resolves its id
|
||||
/// against the comments already read, and was dropped when the notes came first.
|
||||
#[test]
|
||||
fn comment_ranges_inside_notes_are_kept() {
|
||||
let d = read_doc();
|
||||
let body = d.body[0].as_para().unwrap();
|
||||
let refs: Vec<(NoteKind, u32)> = body
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(refs.len(), 2, "{:?}", body.objects);
|
||||
for ((kind, id), (author, text)) in refs.into_iter().zip([("Ann", " Foot."), ("Bob", " End.")]) {
|
||||
let p = d.parts.get(&id).unwrap().blocks[0].as_para().unwrap();
|
||||
let comment = match &p.objects[..] {
|
||||
[InlineObject::NoteRef { kind: k, id: n, .. }, InlineObject::CommentStart { id: c }, InlineObject::CommentEnd { id: e }]
|
||||
if *k == kind && *n == id && c == e =>
|
||||
{
|
||||
*c
|
||||
}
|
||||
o => panic!("{kind:?} {id}: {o:?}"),
|
||||
};
|
||||
assert_eq!(d.comments.get(&comment).unwrap().author, author);
|
||||
assert_eq!(p.text.chars().filter(|c| *c == OBJ).count(), 3);
|
||||
assert_eq!(p.plain_text(), text);
|
||||
}
|
||||
}
|
||||
@@ -403,3 +403,91 @@ fn a_part_reached_under_many_spellings_is_kept_and_written_once() {
|
||||
let written: usize = files.values().map(Vec::len).sum();
|
||||
assert!(written < 4 * 64 * 1024, "{written} bytes written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capitals() {
|
||||
// Part names are case-insensitive: a package storing `WORD/FONTTABLE.XML` and its fonts in
|
||||
// capitals, related in lower case, loaded no fonts, and a save dropped the table and every
|
||||
// font (review of #3872).
|
||||
let table = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><w:fonts xmlns:w="{W}" xmlns:r="{R_NS}"><w:font w:name="Quill Sans"><w:embedRegular r:id="rId1"/></w:font></w:fonts>"#
|
||||
);
|
||||
let table_rels = rels(&[("rId1", &format!("{REL}/font"), "fonts/font1.odttf")]);
|
||||
let font_bytes = vec![9u8; 2048];
|
||||
let ct = CT.replace(
|
||||
"<Default Extension=\"jpeg\"",
|
||||
"<Default Extension=\"odttf\" ContentType=\"application/vnd.openxmlformats-officedocument.obfuscatedFont\"/><Default Extension=\"jpeg\"",
|
||||
);
|
||||
let mut entries: Vec<(&str, Vec<u8>)> = vec![
|
||||
("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")])),
|
||||
("[Content_Types].xml", ct.into_bytes()),
|
||||
(
|
||||
"word/document.xml",
|
||||
format!(r#"<w:document xmlns:w="{W}"><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#).into_bytes(),
|
||||
),
|
||||
("word/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/fontTable"), "fontTable.xml")])),
|
||||
];
|
||||
entries.push(("WORD/FONTTABLE.XML", table.into_bytes()));
|
||||
entries.push(("WORD/_RELS/FONTTABLE.XML.RELS", table_rels));
|
||||
entries.push(("WORD/FONTS/FONT1.ODTTF", font_bytes.clone()));
|
||||
let doc = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
let fonts = wordcraft_docx::embedded_fonts(&doc);
|
||||
assert_eq!(fonts.len(), 1, "the font loads");
|
||||
assert_eq!(fonts[0].data, font_bytes);
|
||||
// And through a save and a reopen.
|
||||
let saved = wordcraft_docx::write(&doc).unwrap();
|
||||
let files = unzip(&saved);
|
||||
assert!(files.keys().any(|k| k.to_ascii_lowercase().ends_with("fonttable.xml")), "{:?}", files.keys());
|
||||
let again = wordcraft_docx::read(&saved).unwrap();
|
||||
let fonts = wordcraft_docx::embedded_fonts(&again);
|
||||
assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys());
|
||||
assert_eq!(fonts[0].data, font_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_ids_keep_sparse_threads_reservations_and_missing_parts() {
|
||||
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
|
||||
let mut doc = Document::from_text("Original thread fixture.");
|
||||
// New IDs must skip the exact durable ID reserved by a later comment, even one whose
|
||||
// missing part keeps it out of commentsIds.xml. Duplicate imported IDs are reassigned.
|
||||
let rows = [
|
||||
(3, None, Some(90), false, true),
|
||||
(11, Some("5a1b2c3d"), Some(90), true, true),
|
||||
(25, Some("5A1B2C3D"), Some(999), false, true),
|
||||
(90, Some("10000001"), None, true, true),
|
||||
(999, Some("10000002"), None, false, false),
|
||||
(u32::MAX, Some("not-hex"), Some(3), false, true),
|
||||
];
|
||||
for (id, durable, parent, resolved, has_part) in rows {
|
||||
let part = id;
|
||||
if has_part {
|
||||
doc.parts.insert(
|
||||
part,
|
||||
Part {
|
||||
kind: PartKind::Comment,
|
||||
blocks: vec![para_block(Paragraph::with_text(&format!("Comment {id} β"), Default::default()))],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
doc.comments.insert(id, Comment { part, parent, resolved, durable_id: durable.map(str::to_string), ..Default::default() });
|
||||
}
|
||||
let files = save(&doc);
|
||||
let (ids, _) = written_ids(&files);
|
||||
assert_eq!(ids.iter().map(|(_, id)| id.as_str()).collect::<Vec<_>>(), ["10000003", "5A1B2C3D", "10000004", "10000001", "10000005"]);
|
||||
let ex = text(&files, "word/commentsExtended.xml");
|
||||
assert_eq!(ex.matches("<w15:commentEx ").count(), 5);
|
||||
assert!(ex.contains(r#"w15:paraId="10000001" w15:paraIdParent="10000004" w15:done="0""#));
|
||||
assert!(ex.contains(r#"w15:paraId="10000002" w15:paraIdParent="10000004" w15:done="1""#));
|
||||
assert!(ex.contains(r#"w15:paraId="10000003" w15:done="0""#), "a missing parent part has no paragraph ID");
|
||||
assert!(ex.contains(r#"w15:paraId="10000006" w15:paraIdParent="10000001" w15:done="0""#));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
// Reader IDs need not match the sparse input IDs. Check the thread by authored text.
|
||||
let comment = |text: &str| back.comments.iter().find(|(_, c)| back.plain_text(wordcraft_doc::StoryRef::Part(c.part)).trim() == text).unwrap();
|
||||
let (root, _) = comment("Comment 90 β");
|
||||
assert_eq!(comment("Comment 3 β").1.parent, Some(*root));
|
||||
assert_eq!(comment("Comment 11 β").1.parent, Some(*root));
|
||||
assert!(comment("Comment 11 β").1.resolved);
|
||||
let (first, _) = comment("Comment 3 β");
|
||||
assert_eq!(comment(&format!("Comment {} β", u32::MAX)).1.parent, Some(*first));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ fn every_char_prop_round_trips() {
|
||||
caps: Some(true),
|
||||
small_caps: Some(false),
|
||||
hidden: Some(true),
|
||||
web_hidden: Some(true),
|
||||
snap_to_grid: Some(false),
|
||||
spacing: Some(1.5),
|
||||
scale: Some(150.0),
|
||||
position: Some(3.0),
|
||||
@@ -2262,9 +2264,46 @@ fn bibliography_sources_round_trip() {
|
||||
});
|
||||
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
|
||||
// Verify package-root customXml parts exist
|
||||
assert_bibliography_parts(&bytes);
|
||||
assert_bibliography_sources_round_trip(&bytes);
|
||||
}
|
||||
|
||||
/// #4848: a corporate author saves as Word's `b:Corporate`, in place of a name list (a role holds
|
||||
/// one or the other), and reads back whole.
|
||||
#[test]
|
||||
fn a_corporate_author_round_trips() {
|
||||
let mut d = Document::new();
|
||||
d.sources.push(Source {
|
||||
tag: "Wor20".into(),
|
||||
kind: "report".into(),
|
||||
corporate: "World Health Organization".into(),
|
||||
title: "World report on hearing".into(),
|
||||
year: "2020".into(),
|
||||
editor: "Chadha, Shelly".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Both".into(),
|
||||
author: "Rivera, Alex".into(),
|
||||
corporate: "Harbor Guild, Inc.".into(),
|
||||
title: "Shared Spaces".into(),
|
||||
..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();
|
||||
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}");
|
||||
let back = wordcraft_docx::read(&bytes).expect("read");
|
||||
let got: Vec<(&str, &str, &str)> = back.sources.iter().map(|s| (s.corporate.as_str(), s.author.as_str(), s.editor.as_str())).collect();
|
||||
assert_eq!(got, [("World Health Organization", "", "Chadha, Shelly"), ("Harbor Guild, Inc.", "", "")]);
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -2281,6 +2320,12 @@ fn bibliography_sources_round_trip() {
|
||||
|
||||
let mut item_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
|
||||
assert_bibliography_types(&item_xml);
|
||||
assert_bibliography_people(&item_xml);
|
||||
assert_bibliography_details(&item_xml);
|
||||
}
|
||||
|
||||
fn assert_bibliography_types(item_xml: &str) {
|
||||
assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}");
|
||||
assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>Book</b:SourceType>"), "{item_xml}");
|
||||
@@ -2288,12 +2333,18 @@ fn bibliography_sources_round_trip() {
|
||||
assert!(item_xml.contains("<b:SourceType>InternetSite</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>BookSection</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>Misc</b:SourceType>"), "{item_xml}");
|
||||
}
|
||||
|
||||
fn assert_bibliography_people(item_xml: &str) {
|
||||
assert!(item_xml.contains("<b:Author><b:Author><b:NameList><b:Person>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Rivera</b:Last><b:First>Alex</b:First>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Smith</b:Last><b:First>John</b:First><b:Middle>David</b:Middle>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Solo</b:Last>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Editor>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Johnson</b:Last><b:First>Robert</b:First>"), "{item_xml}");
|
||||
}
|
||||
|
||||
fn assert_bibliography_details(item_xml: &str) {
|
||||
assert!(item_xml.contains("<b:Edition>2nd</b:Edition>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Month>10</b:Month>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Day>15</b:Day>"), "{item_xml}");
|
||||
@@ -2303,50 +2354,68 @@ fn bibliography_sources_round_trip() {
|
||||
assert!(item_xml.contains("<b:MonthAccessed>05</b:MonthAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:DayAccessed>15</b:DayAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Accessed>2024-05-15</b:Accessed>"), "{item_xml}");
|
||||
}
|
||||
|
||||
let back = wordcraft_docx::read(&bytes).expect("read");
|
||||
fn assert_bibliography_sources_round_trip(bytes: &[u8]) {
|
||||
let back = wordcraft_docx::read(bytes).expect("read");
|
||||
assert_eq!(back.sources.len(), 5);
|
||||
assert_eq!(back.sources[0].tag, "Rivera2021");
|
||||
assert_eq!(back.sources[0].kind, "book");
|
||||
assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei");
|
||||
assert_eq!(back.sources[0].title, "Shared Spaces");
|
||||
assert_eq!(back.sources[0].year, "2021");
|
||||
assert_eq!(back.sources[0].publisher, "Harbor Books");
|
||||
assert_eq!(back.sources[0].city, "Portland");
|
||||
assert_eq!(back.sources[0].editor, "Johnson, Robert");
|
||||
assert_eq!(back.sources[0].edition, "2nd");
|
||||
assert_eq!(back.sources[0].month, "10");
|
||||
assert_eq!(back.sources[0].day, "15");
|
||||
assert_bibliography_source_0(&back.sources[0]);
|
||||
assert_bibliography_source_1(&back.sources[1]);
|
||||
assert_bibliography_source_2(&back.sources[2]);
|
||||
assert_bibliography_source_3(&back.sources[3]);
|
||||
assert_bibliography_source_4(&back.sources[4]);
|
||||
}
|
||||
|
||||
assert_eq!(back.sources[1].tag, "Smith2023");
|
||||
assert_eq!(back.sources[1].kind, "article");
|
||||
assert_eq!(back.sources[1].author, "Smith, John David");
|
||||
assert_eq!(back.sources[1].title, "Modern Typography");
|
||||
assert_eq!(back.sources[1].journal, "Design Quarterly");
|
||||
assert_eq!(back.sources[1].volume, "12");
|
||||
assert_eq!(back.sources[1].pages, "45-60");
|
||||
assert_eq!(back.sources[1].url, "https://example.com/typo");
|
||||
assert_eq!(back.sources[1].issue, "4");
|
||||
assert_eq!(back.sources[1].doi, "10.1000/182");
|
||||
fn assert_bibliography_source_0(source: &Source) {
|
||||
assert_eq!(source.tag, "Rivera2021");
|
||||
assert_eq!(source.kind, "book");
|
||||
assert_eq!(source.author, "Rivera, Alex; Chen, Mei");
|
||||
assert_eq!(source.title, "Shared Spaces");
|
||||
assert_eq!(source.year, "2021");
|
||||
assert_eq!(source.publisher, "Harbor Books");
|
||||
assert_eq!(source.city, "Portland");
|
||||
assert_eq!(source.editor, "Johnson, Robert");
|
||||
assert_eq!(source.edition, "2nd");
|
||||
assert_eq!(source.month, "10");
|
||||
assert_eq!(source.day, "15");
|
||||
}
|
||||
|
||||
assert_eq!(back.sources[2].tag, "WebSite2024");
|
||||
assert_eq!(back.sources[2].kind, "website");
|
||||
assert_eq!(back.sources[2].author, "Solo");
|
||||
assert_eq!(back.sources[2].title, "Open Fonts");
|
||||
assert_eq!(back.sources[2].url, "https://example.org");
|
||||
assert_eq!(back.sources[2].accessed, "2024-05-15");
|
||||
fn assert_bibliography_source_1(source: &Source) {
|
||||
assert_eq!(source.tag, "Smith2023");
|
||||
assert_eq!(source.kind, "article");
|
||||
assert_eq!(source.author, "Smith, John David");
|
||||
assert_eq!(source.title, "Modern Typography");
|
||||
assert_eq!(source.journal, "Design Quarterly");
|
||||
assert_eq!(source.volume, "12");
|
||||
assert_eq!(source.pages, "45-60");
|
||||
assert_eq!(source.url, "https://example.com/typo");
|
||||
assert_eq!(source.issue, "4");
|
||||
assert_eq!(source.doi, "10.1000/182");
|
||||
}
|
||||
|
||||
assert_eq!(back.sources[3].tag, "Chapter2025");
|
||||
assert_eq!(back.sources[3].kind, "bookSection");
|
||||
assert_eq!(back.sources[3].author, "Lee, Harper");
|
||||
assert_eq!(back.sources[3].title, "Chapter One");
|
||||
assert_eq!(back.sources[3].journal, "Anthology of Prose");
|
||||
assert_eq!(back.sources[3].pages, "1-20");
|
||||
fn assert_bibliography_source_2(source: &Source) {
|
||||
assert_eq!(source.tag, "WebSite2024");
|
||||
assert_eq!(source.kind, "website");
|
||||
assert_eq!(source.author, "Solo");
|
||||
assert_eq!(source.title, "Open Fonts");
|
||||
assert_eq!(source.url, "https://example.org");
|
||||
assert_eq!(source.accessed, "2024-05-15");
|
||||
}
|
||||
|
||||
assert_eq!(back.sources[4].tag, "Misc2026");
|
||||
assert_eq!(back.sources[4].kind, "other");
|
||||
assert_eq!(back.sources[4].title, "Archive Manuscript");
|
||||
assert_eq!(back.sources[4].city, "Oxford");
|
||||
fn assert_bibliography_source_3(source: &Source) {
|
||||
assert_eq!(source.tag, "Chapter2025");
|
||||
assert_eq!(source.kind, "bookSection");
|
||||
assert_eq!(source.author, "Lee, Harper");
|
||||
assert_eq!(source.title, "Chapter One");
|
||||
assert_eq!(source.journal, "Anthology of Prose");
|
||||
assert_eq!(source.pages, "1-20");
|
||||
}
|
||||
|
||||
fn assert_bibliography_source_4(source: &Source) {
|
||||
assert_eq!(source.tag, "Misc2026");
|
||||
assert_eq!(source.kind, "other");
|
||||
assert_eq!(source.title, "Archive Manuscript");
|
||||
assert_eq!(source.city, "Oxford");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 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>"#;
|
||||
let document = format!(
|
||||
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()));
|
||||
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.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
|
||||
let styles = r#"
|
||||
<w:style w:type="character" w:styleId="On"><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr></w:style>
|
||||
<w:style w:type="character" w:styleId="Off"><w:rPr><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr></w:style>
|
||||
"#;
|
||||
let body = r#"
|
||||
<w:p>
|
||||
<w:r><w:rPr><w:rStyle w:val="On"/></w:rPr><w:t>inherit</w:t></w:r>
|
||||
<w:r><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr><w:t>direct true</w:t></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="On"/><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr><w:t>override</w:t></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="Off"/><w:vanish/></w:rPr><w:t>visible in print</w:t></w:r>
|
||||
<w:r><w:t>absent</w:t></w:r>
|
||||
</w:p>
|
||||
"#;
|
||||
let mut doc = wordcraft_docx::read(&docx(body, styles)).unwrap();
|
||||
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.runs[0].props.snap_to_grid, None);
|
||||
assert_eq!(p.runs[0].props.web_hidden, None);
|
||||
assert_eq!(p.runs[1].props.snap_to_grid, Some(true));
|
||||
assert_eq!(p.runs[1].props.web_hidden, Some(true));
|
||||
assert_eq!(p.runs[2].props.snap_to_grid, Some(false));
|
||||
assert_eq!(p.runs[2].props.web_hidden, Some(false));
|
||||
assert_eq!(p.runs[3].props.hidden, Some(true));
|
||||
assert_eq!(p.runs[3].props.web_hidden, None);
|
||||
assert_eq!(p.runs[4].props.snap_to_grid, None);
|
||||
assert_eq!(p.runs[4].props.web_hidden, None);
|
||||
assert!(!doc.styles.resolve_char(None, &p.runs[0].props).hidden, "w:webHidden does not hide Print Layout text");
|
||||
assert!(doc.styles.resolve_char(None, &p.runs[3].props).hidden, "w:vanish still controls hidden text");
|
||||
for pass in 0..2 {
|
||||
let out = wordcraft_docx::write(&doc).unwrap();
|
||||
let style_xml = part(&out, "word/styles.xml");
|
||||
assert!(style_xml.contains(r#"<w:snapToGrid/><w:webHidden/>"#), "pass {pass}: {style_xml}");
|
||||
assert!(style_xml.contains(r#"<w:snapToGrid w:val="0"/><w:webHidden w:val="0"/>"#), "pass {pass}: {style_xml}");
|
||||
let document_xml = part(&out, "word/document.xml");
|
||||
assert!(document_xml.contains(r#"<w:snapToGrid w:val="0"/>"#), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains(r#"<w:webHidden w:val="0"/>"#), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("<w:snapToGrid/><w:webHidden/>"), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("<w:vanish/>"), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("visible in print"));
|
||||
doc = wordcraft_docx::read(&out).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Export timing: saves a long generated document (the built-in sample document repeated) through
|
||||
//! the real save path, the way `file.save` / `file.export` write it, and prints wall times.
|
||||
//!
|
||||
//! `cargo run --release -p wordcraft-engine --example export_bench -- [ext] [copies] [runs] [out]`
|
||||
//!
|
||||
//! Defaults: `pdf`, 40 copies (about 100 pages), 7 runs. With `out` the last run's bytes are
|
||||
//! written there, to compare the output of two builds.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use wordcraft_engine::doc::props::CharProps;
|
||||
use wordcraft_engine::doc::{Block, Document};
|
||||
use wordcraft_engine::{io, sample};
|
||||
|
||||
/// The sample document's body `copies` times, each paragraph ending in its copy number so no two
|
||||
/// copies share text (as the chapters of a real long document don't).
|
||||
fn long_document(copies: usize) -> Document {
|
||||
let mut d = sample::sample_document();
|
||||
// A fixed creation date: the PDF's dates (and the ID derived from them) stay the same from run to run.
|
||||
d.core.created = "2026-01-01T00:00:00Z".into();
|
||||
let one: Vec<Block> = d.body.iter().map(|b| (**b).clone()).collect();
|
||||
d.body.clear();
|
||||
for i in 0..copies {
|
||||
for b in &one {
|
||||
let mut b = b.clone();
|
||||
if let Block::Para(p) = &mut b {
|
||||
let at = p.len();
|
||||
let _ = p.insert_text(at, &format!(" {i}"), &CharProps::default());
|
||||
}
|
||||
d.body.push(Arc::new(b));
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let ext = args.first().map_or("pdf", String::as_str);
|
||||
let copies = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(40);
|
||||
let runs = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(7usize).max(1);
|
||||
let doc = long_document(copies);
|
||||
let name = format!("bench.{ext}");
|
||||
let mut ms = Vec::with_capacity(runs);
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..runs {
|
||||
let t = Instant::now();
|
||||
bytes = io::save_bytes(&name, &doc)?;
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let mut sorted = ms.clone();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let median = sorted.get(sorted.len() / 2).copied().unwrap_or_default();
|
||||
let min = sorted.first().copied().unwrap_or_default();
|
||||
println!(
|
||||
"{ext}: {copies} copies, {} bytes, runs {:?} ms, median {median:.1} ms, min {min:.1} ms",
|
||||
bytes.len(),
|
||||
ms.iter().map(|m| m.round()).collect::<Vec<_>>()
|
||||
);
|
||||
if let Some(out) = args.get(3) {
|
||||
std::fs::write(out, &bytes).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::generated::page_of;
|
||||
use super::*;
|
||||
use super::{CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
|
||||
|
||||
pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let to = p::str(v, "to").unwrap_or("heading");
|
||||
@@ -77,13 +77,19 @@ pub(super) fn caption_label(p: &Paragraph, text: &str) -> Option<String> {
|
||||
(!label.is_empty() && label.chars().count() <= 40).then_some(label)
|
||||
}
|
||||
|
||||
/// Whether `p` is a caption: its style is Caption or one based on it, as a caption restyled with a
|
||||
/// style of its own (a `Table Caption` based on Caption) still is (#4411).
|
||||
pub(super) fn is_caption(doc: &Document, p: &Paragraph) -> bool {
|
||||
p.props.style.as_deref().is_some_and(|id| id == "Caption" || doc.styles.chain(id).iter().any(|s| s.id == "Caption"))
|
||||
}
|
||||
|
||||
/// The caption paragraphs of the body: (text, position, label).
|
||||
fn captions(s: &Session) -> Vec<(String, Pos, Option<String>)> {
|
||||
s.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
let p = s.doc.para(StoryRef::Body, &path).filter(|p| p.props.style.as_deref() == Some("Caption"))?;
|
||||
let p = s.doc.para(StoryRef::Body, &path).filter(|p| is_caption(&s.doc, p))?;
|
||||
let text = p.plain_text();
|
||||
let label = caption_label(p, &text);
|
||||
Some((text, Pos { story: StoryRef::Body, path, off: 0 }, label))
|
||||
@@ -125,7 +131,7 @@ pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
|
||||
};
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = s.doc.para(StoryRef::Body, &path)
|
||||
&& p.props.style.as_deref() == Some("Caption")
|
||||
&& is_caption(&s.doc, p)
|
||||
&& p.plain_text().starts_with(label)
|
||||
{
|
||||
targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::cross_refs::caption_label;
|
||||
use super::cross_refs::{caption_label, is_caption};
|
||||
use super::render::entry;
|
||||
use super::sources::bib_order;
|
||||
use super::styles::style;
|
||||
use super::*;
|
||||
use super::{
|
||||
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
|
||||
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
|
||||
};
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
|
||||
@@ -83,8 +86,8 @@ fn figures_label(instr: &str) -> Option<String> {
|
||||
|
||||
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
|
||||
/// caption typed by hand, without a SEQ field, goes by its label.
|
||||
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
|
||||
if p.props.style.as_deref() != Some("Caption") {
|
||||
fn captioned_as(doc: &Document, p: &Paragraph, ident: &str) -> bool {
|
||||
if !is_caption(doc, p) {
|
||||
return false;
|
||||
}
|
||||
let seq = p.objects.iter().find_map(|o| match o {
|
||||
@@ -253,7 +256,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
if captioned_as(&s.doc, p, ident) {
|
||||
let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
|
||||
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
}
|
||||
@@ -277,7 +280,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
if captioned_as(&s.doc, p, ident) {
|
||||
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::Source;
|
||||
use super::styles::{Cite, Name, Names, style};
|
||||
use super::*;
|
||||
|
||||
/// A source's corporate author, when it has one: written as it is in place of the person names
|
||||
/// (Word's Corporate Author, #4848).
|
||||
pub(super) fn corporate(src: &Source) -> Option<&str> {
|
||||
Some(src.corporate.trim()).filter(|c| !c.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn authors(a: &str) -> Vec<(String, String)> {
|
||||
a.split(';')
|
||||
@@ -110,12 +116,13 @@ pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String {
|
||||
let st = style(style_name);
|
||||
let au = authors(&src.author);
|
||||
let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect();
|
||||
let who = match lasts.as_slice() {
|
||||
[] => src.title.clone(),
|
||||
[one] => one.to_string(),
|
||||
[first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
|
||||
[a, b] => format!("{a} {} {b}", st.and),
|
||||
[init @ .., z] => format!("{} {} {z}", init.join(", "), st.and),
|
||||
let who = match (corporate(src), lasts.as_slice()) {
|
||||
(Some(c), _) => c.to_string(),
|
||||
(None, []) => src.title.clone(),
|
||||
(None, [one]) => one.to_string(),
|
||||
(None, [first, ..]) if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
|
||||
(None, [a, b]) => format!("{a} {} {b}", st.and),
|
||||
(None, [init @ .., z]) => format!("{} {} {z}", init.join(", "), st.and),
|
||||
};
|
||||
match st.cite {
|
||||
Cite::AuthorPage if pages.is_empty() => format!("({who})"),
|
||||
@@ -148,7 +155,7 @@ pub fn entry(src: &Source, style_name: &str, n: usize) -> String {
|
||||
let field = |key: &str| -> String {
|
||||
match key {
|
||||
"n" => n.to_string(),
|
||||
"names" => name_list(&au, &st.names),
|
||||
"names" => corporate(src).map_or_else(|| name_list(&au, &st.names), str::to_string),
|
||||
"year" if src.year.is_empty() => "n.d.".into(),
|
||||
"year" => src.year.clone(),
|
||||
"title" => src.title.clone(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::generated::update_generated;
|
||||
use super::render::{authors, cite};
|
||||
use super::render::{authors, cite, corporate};
|
||||
use super::styles::{CiteStyle, Order, style};
|
||||
use super::*;
|
||||
use super::{CmdError, CmdResult, Document, InlineObject, Selection, Session, Source, StoryRef, Value, delete_selection, json, p, sort_key};
|
||||
|
||||
/// The tag a new source gets, as Word makes it: the first three letters of the first author's
|
||||
/// name and the last two digits of the year (`Smi20`), with `1`, `2`… added while it is taken.
|
||||
@@ -14,18 +14,19 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
|
||||
(1u32..).map(|n| format!("{base}{n}")).find(|t| !taken(t)).unwrap_or(base)
|
||||
}
|
||||
|
||||
/// The author of a source for matching untagged entries: the editor when there is no author.
|
||||
/// The author of a source for matching untagged entries: the corporate author (which takes the
|
||||
/// author's place when a source is saved), else the author, else the editor.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn by_author(x: &Source) -> &str {
|
||||
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
|
||||
[x.corporate.trim(), x.author.as_str(), x.editor.as_str()].into_iter().find(|w| !w.is_empty()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The stem of a new source's tag, before `1`, `2`… are added while it is taken: the first three
|
||||
/// alphanumerics of the first author's name (nothing of the author's, `Src`) and the last two
|
||||
/// digits of the year.
|
||||
/// alphanumerics of the first author's name or the corporate author (nothing of the author's,
|
||||
/// `Src`) and the last two digits of the year.
|
||||
fn auto_tag_base(src: &Source) -> String {
|
||||
let name: String =
|
||||
authors(&src.author).first().map(|x| x.0.clone()).unwrap_or_default().chars().filter(|c| c.is_alphanumeric()).take(3).collect();
|
||||
let first = corporate(src).map(str::to_string).or_else(|| authors(&src.author).first().map(|x| x.0.clone())).unwrap_or_default();
|
||||
let name: String = first.chars().filter(|c| c.is_alphanumeric()).take(3).collect();
|
||||
let name = if name.is_empty() { "Src".to_string() } else { name };
|
||||
let digits: Vec<char> = src.year.chars().filter(char::is_ascii_digit).collect();
|
||||
let year: String = digits.get(digits.len().saturating_sub(2)..).unwrap_or_default().iter().collect();
|
||||
@@ -184,8 +185,13 @@ fn cited_tags(doc: &Document) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A source's place in an author-sorted bibliography: the corporate author, else the first
|
||||
/// author's surname, else the title.
|
||||
fn author_key(src: &Source) -> String {
|
||||
authors(&src.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| src.title.to_lowercase())
|
||||
corporate(src)
|
||||
.map(str::to_lowercase)
|
||||
.or_else(|| authors(&src.author).first().map(|a| a.0.to_lowercase()))
|
||||
.unwrap_or_else(|| src.title.to_lowercase())
|
||||
}
|
||||
|
||||
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
|
||||
|
||||
@@ -103,8 +103,10 @@ pub static STYLES: [CiteStyle; 12] = [
|
||||
et_al_from: 3,
|
||||
order: Order::Author,
|
||||
names: names(Name::LastInitials, Name::LastInitials, ", ", ", & ", ", & "),
|
||||
book: "⟦{names} ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
|
||||
article: "⟦{names} ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
|
||||
// The names end with a full stop: an initial's, or one added after a group author or a
|
||||
// name without initials ("World Health Organization. (2021).", #4848).
|
||||
book: "⟦{names}. ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
|
||||
article: "⟦{names}. ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
|
||||
bib_title: "References",
|
||||
},
|
||||
CiteStyle {
|
||||
|
||||
@@ -18,6 +18,49 @@ fn citations_and_bibliography() {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
|
||||
}
|
||||
|
||||
/// #4848: a corporate author is cited and listed as written, in every style: never turned into
|
||||
/// a surname and initials ("Organization, W. H."), never left out for the title. Its tag and its
|
||||
/// place in an author-sorted list come from the name too.
|
||||
#[test]
|
||||
fn a_corporate_author_is_cited_and_listed_as_written() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "Hearing loss is common "})).unwrap();
|
||||
let who = "World Health Organization";
|
||||
s.run("references.citation", &json!({"source": {"kind": "report", "corporate": who, "title": "World report on hearing", "year": "2021", "publisher": "WHO Press", "city": "Geneva"}}))
|
||||
.unwrap();
|
||||
s.run("text.insert", &json!({"text": " and rising "})).unwrap();
|
||||
s.run("references.citation", &json!({"source": {"author": "Adams, Kim", "title": "Quiet Rooms", "year": "2019"}})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.bibliography", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.sources[0].tag, "Wor21", "the tag starts with the corporate name");
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(t.contains(&format!("({who}, 2021)")), "{t}");
|
||||
assert!(t.contains(&format!("{who}. (2021). World report on hearing. WHO Press.")), "{t}");
|
||||
// Sorted by the corporate name: Adams before World.
|
||||
let adams = t.find("Adams, K. (2019)").unwrap_or(usize::MAX);
|
||||
let world = t.find(&format!("{who}. (2021)")).unwrap_or(0);
|
||||
assert!(adams < world, "{t}");
|
||||
for style in [
|
||||
"MLA",
|
||||
"Chicago",
|
||||
"GB/T 7714",
|
||||
"GOST (name sort)",
|
||||
"GOST (title sort)",
|
||||
"ISO 690 (author-date)",
|
||||
"ISO 690 (numerical)",
|
||||
"Harvard (Anglia)",
|
||||
"Turabian",
|
||||
"IEEE",
|
||||
"SIST02",
|
||||
] {
|
||||
s.run("references.citationStyle", &json!({"style": style})).unwrap();
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
let entry = t.lines().find(|l| l.contains("World report on hearing")).unwrap_or_default();
|
||||
assert!(entry.contains(who), "{style}: {entry}");
|
||||
assert!(!entry.contains("Organization, W") && !entry.contains("ORGANIZATION, W"), "{style}: {entry}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Updating fields in a document without generated lists leaves its styles alone: a new
|
||||
/// style would make the next layout start over from nothing.
|
||||
#[test]
|
||||
@@ -162,6 +205,49 @@ fn caption_labels_do_not_need_a_digit() {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// #4411: captions restyled with a style based on Caption, directly (`Caption Table`) or through
|
||||
/// another (`Caption Table Wide`), are still captions after a DOCX save and reopen: every `tableX`
|
||||
/// caption is a cross-reference target and in the table of figures, and a Table caption is a
|
||||
/// Table target. Captions in a style not based on Caption are not, nor are their labels.
|
||||
#[test]
|
||||
fn captions_in_a_style_based_on_caption() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("T1\nT2\nT3\nT4\nT5\nNote\nGrid\nStone\nSee "));
|
||||
let caption = |s: &mut Session, text: &str, label: &str, title: &str| {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("references.caption", &json!({"label": label, "text": title})).unwrap();
|
||||
};
|
||||
for k in 1..=5 {
|
||||
caption(&mut s, &format!("T{k}"), "tableX", &format!("Row {k}"));
|
||||
}
|
||||
caption(&mut s, "Note", "tableX", "Aside");
|
||||
caption(&mut s, "Grid", "Table", "Grid");
|
||||
caption(&mut s, "Stone", "Plate", "Stone");
|
||||
s.run("styles.create", &json!({"name": "Caption Table", "basedOn": "Caption", "fromSelection": false})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Caption Table Wide", "basedOn": "Caption Table", "fromSelection": false})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Aside", "fromSelection": false})).unwrap();
|
||||
let style = |s: &Session, name: &str| s.doc.styles.find(name).map(|st| st.id.clone());
|
||||
let (table, wide, aside) = (style(&s, "Caption Table"), style(&s, "Caption Table Wide"), style(&s, "Aside"));
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let p = s.doc.para_mut(StoryRef::Body, &path).unwrap();
|
||||
let text = p.plain_text();
|
||||
p.props.style = match text.get(..8).unwrap_or_default() {
|
||||
"tableX 1" | "tableX 2" => table.clone(),
|
||||
"tableX 3" | "Table 1:" => wide.clone(),
|
||||
"tableX 6" | "Plate 1:" => aside.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).expect("docx read"));
|
||||
let want: Vec<String> = (1..=5).map(|k| format!("tableX {k}: Row {k}")).collect();
|
||||
assert_eq!(s.run("insert.crossReference", &json!({"to": "label:tableX"})).unwrap(), json!(want));
|
||||
assert_eq!(s.run("insert.crossReference", &json!({"to": "table"})).unwrap(), json!(["Table 1: Grid"]));
|
||||
assert_eq!(caption_labels(&s), ["tableX", "Table"]);
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "tableX", "pageNumbers": false})).unwrap();
|
||||
assert_eq!(list_entries(&s).into_iter().map(|(t, _)| t).collect::<Vec<_>>(), want);
|
||||
}
|
||||
|
||||
/// The entries of every generated list, in order, with their last tab stop's leader.
|
||||
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
|
||||
s.doc
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Block, Paragraph, Pos};
|
||||
use wordcraft_layout::hit::ColumnSelection;
|
||||
|
||||
use super::{parse_pos, pos_json};
|
||||
use crate::{CmdError, CmdResult, ColumnBlock, CommandSpec, Selection, Session, p};
|
||||
@@ -96,7 +97,7 @@ fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
|
||||
let (xa, xb) = (if xa.is_finite() { xa } else { 0.0 }, if xb.is_finite() { xb } else { 0.0 });
|
||||
let (left, right) = (xa.min(xb), xa.max(xb));
|
||||
let l = s.layout();
|
||||
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
|
||||
let segments = l.column_segments(&s.doc, ColumnSelection { start: &a, end: &b, left, right, page_hint: s.page_hint, max_lines: MAX_LINES });
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });
|
||||
s.pending = None;
|
||||
@@ -179,6 +180,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
|
||||
// from it without clearing it, as in Word.
|
||||
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
|
||||
// Copy and Cut of whole cells take a table of them (#1744).
|
||||
"edit.copy" | "edit.cut" if cells => run(s, v),
|
||||
"edit.copy" => copy(s, &segs),
|
||||
"edit.cut" => {
|
||||
let r = copy(s, &segs)?;
|
||||
|
||||
@@ -48,10 +48,10 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
|
||||
let (mut ids, mut conns, mut groups) = (HashMap::new(), Vec::new(), Vec::new());
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for off in para.object_offsets() {
|
||||
for (off, obj) in para.objects_with_offsets() {
|
||||
let pos = Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
match para.object_at(off) {
|
||||
Some(InlineObject::Shape { kind, extra, float, .. }) => {
|
||||
match obj {
|
||||
InlineObject::Shape { kind, extra, float, .. } => {
|
||||
if extra.id != 0 {
|
||||
ids.entry(extra.id).or_insert(pos.clone());
|
||||
}
|
||||
@@ -59,7 +59,7 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
|
||||
conns.push(pos);
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Group { children, .. })
|
||||
InlineObject::Group { children, .. }
|
||||
if children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { kind, extra, .. } if kind.is_connector() && (extra.start.is_some() || extra.end.is_some()))) =>
|
||||
{
|
||||
groups.push(pos)
|
||||
@@ -91,10 +91,11 @@ pub fn reroute(s: &mut Session) -> bool {
|
||||
s.touch();
|
||||
}
|
||||
let layout = s.layout();
|
||||
let objects = layout.object_index();
|
||||
let site = |e: Option<ConnEnd>, page: usize| -> Option<(f32, f32)> {
|
||||
let e = e?;
|
||||
let target = ids.get(&e.id)?;
|
||||
let hit = layout.object(target, page)?;
|
||||
let hit = objects.object(target, page)?;
|
||||
let kind = match s.doc.para_at(target)?.object_at(target.off)? {
|
||||
InlineObject::Shape { kind, .. } => *kind,
|
||||
_ => return None,
|
||||
@@ -103,7 +104,7 @@ pub fn reroute(s: &mut Session) -> bool {
|
||||
};
|
||||
let mut plans = Vec::new();
|
||||
for pos in conns {
|
||||
let Some(hit) = layout.object(&pos, s.page_hint) else { continue };
|
||||
let Some(hit) = objects.object(&pos, s.page_hint) else { continue };
|
||||
let Some(InlineObject::Shape { extra, float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue };
|
||||
let r = hit.rect;
|
||||
let (s0, e0) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v);
|
||||
@@ -313,6 +314,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawing commands must not search every page again for each glued connector.
|
||||
#[test]
|
||||
fn rerouting_many_pages_does_not_rescan_all_objects_per_connector() {
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
let mut doc = Document::new();
|
||||
doc.body = (0..8000u32)
|
||||
.map(|i| {
|
||||
let mut p = Paragraph::default();
|
||||
p.props.page_break_before = Some(i > 0);
|
||||
let id = i * 2 + 1;
|
||||
for (kind, x, extra) in [
|
||||
(ShapeKind::Rectangle, 100.0, ShapeExtra { id, ..Default::default() }),
|
||||
(ShapeKind::Ellipse, 300.0, ShapeExtra { id: id + 1, ..Default::default() }),
|
||||
(
|
||||
ShapeKind::ElbowConnector,
|
||||
0.0,
|
||||
ShapeExtra { start: Some(ConnEnd { id, site: 3 }), end: Some(ConnEnd { id: id + 1, site: 2 }), ..Default::default() },
|
||||
),
|
||||
] {
|
||||
let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() };
|
||||
p.insert_object(p.len(), shape(kind, extra, float), &Default::default()).unwrap();
|
||||
}
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let mut s = Session::new(doc);
|
||||
s.view.proofing = false;
|
||||
s.page_hint = 0;
|
||||
assert_eq!(s.layout().pages.len(), 8000);
|
||||
let started = std::time::Instant::now();
|
||||
assert!(reroute(&mut s));
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!("8000-page reroute: {elapsed:?}");
|
||||
assert!(elapsed < std::time::Duration::from_secs(5), "reroute took {elapsed:?}");
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let para = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
let InlineObject::Shape { w, h, float, .. } = para.object_at(6).unwrap() else { panic!("connector") };
|
||||
assert_eq!((*w, *h, float.x, float.y), (160.0, 0.0, 140.0, 210.0));
|
||||
}
|
||||
s.touch();
|
||||
assert!(!reroute(&mut s), "rerouting must settle after refreshing the layout");
|
||||
}
|
||||
|
||||
/// The per-case shape/geometry of one scaled-group reroute case.
|
||||
struct ScaledGroupSpec {
|
||||
w: f32,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
use wordcraft_doc::{Path, Pos, StoryRef};
|
||||
|
||||
use super::{delete_selection, find_pattern, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -27,10 +27,16 @@ fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
/// Something the Spike can collect: text (it doesn't take empty cells).
|
||||
fn has_text_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
|
||||
/// Something to cut: text, or whole table cells (an empty cell's selection has no length, #1744).
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() && s.cell_range().is_none() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
|
||||
@@ -63,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
|
||||
// Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick
|
||||
// Parts and AutoText insert it without emptying it, as in Word.
|
||||
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel),
|
||||
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_text_sel),
|
||||
CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| {
|
||||
if s.math.is_some() {
|
||||
Some("leave the equation first")
|
||||
@@ -167,11 +173,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let whole = super::table::selected_table(s);
|
||||
copy_with(s, whole.as_ref())
|
||||
}
|
||||
|
||||
/// Copy, `whole` being the table the selection covers whole, if it does.
|
||||
fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
return Ok(json!({"text": ""}));
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
// Whole table cells selected copy as a table, even empty ones (their selection has no
|
||||
// length): the whole table, or a table of just the selected rows or cells (#1744).
|
||||
let table = match (whole, s.cell_range()) {
|
||||
(Some(tp), _) => s.doc.copy_block(a.story, tp),
|
||||
(None, Some(r)) => s.doc.copy_table_cells(r.story, &r.table, r.rows, r.cols),
|
||||
(None, None) => None,
|
||||
};
|
||||
let f = match table {
|
||||
Some(f) => f,
|
||||
None if a == b => return Ok(json!({"text": ""})),
|
||||
None => s.doc.copy_range(&a, &b),
|
||||
};
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
@@ -200,8 +220,26 @@ pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
r
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = copy(s, v)?;
|
||||
fn cut(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let whole = super::table::selected_table(s);
|
||||
let r = copy_with(s, whole.as_ref())?;
|
||||
// A whole table selected: Cut removes the table, as Delete Table does (#1744). With Track
|
||||
// Changes on, the cut below marks its text deleted (a tracked cut may become a move).
|
||||
if !s.doc.settings.track_changes
|
||||
&& let Some(tp) = &whole
|
||||
{
|
||||
super::table::delete_table_at(s, tp)?;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
return Ok(r);
|
||||
}
|
||||
// Whole rows or some cells: their text goes, the rows and cells stay, as in Word.
|
||||
if whole.is_none()
|
||||
&& let Some(range) = s.cell_range().cloned()
|
||||
{
|
||||
super::table::clear_range_cells(s, &range)?;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
return Ok(r);
|
||||
}
|
||||
// A tracked cut of a paragraph or sentence may become a move when pasted.
|
||||
if !super::moves::cut_tracked(s)? {
|
||||
delete_selection(s)?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user