Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 3de6ff95af Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:06 -04:00
Brandon ThomasandClaude Opus 5.5 65bb8b3709 macOS packaging: retry stapler when Apple's CloudKit drops the connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:00 -04:00
Dylan M. Taylor c4fafc2154 Enforce Rust coding standards via Clippy (#4501)
* Refactor doc for strict Clippy checks

* Refactor docx for strict Clippy checks

* Refactor engine for strict Clippy checks

* Refactor fonts for strict Clippy checks

* Refactor formats for strict Clippy checks

* Refactor layout for strict Clippy checks

* Refactor mcp for strict Clippy checks

* Refactor pdf for strict Clippy checks

* Refactor proof for strict Clippy checks

* Refactor render for strict Clippy checks

* Refactor ui canvas for strict Clippy checks

* Refactor ui dialogs for strict Clippy checks

* Refactor ui ribbon for strict Clippy checks

* Refactor ui support for strict Clippy checks

* Refactor wordcraft-cli for strict Clippy checks

* Enforce workspace Clippy standards and document lint policy

* Deflake the IME epoch test

* Fix platform-gated imports for wasm builds
2026-10-27 22:00:30 -04:00
Dylan M. Taylor df5a59275d Fix platform-gated imports for wasm builds 2026-10-28 01:24:26 +00:00
Dylan M. Taylor ab91e9f96c Deflake the IME epoch test 2026-10-28 01:06:49 +00:00
Dylan M. Taylor 1c8f6e9257 Enforce workspace Clippy standards and document lint policy 2026-10-28 01:06:48 +00:00
Dylan M. Taylor bc05149bd9 Refactor wordcraft-cli for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 51c70e1606 Refactor ui support for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor ac2cc0c4ef Refactor ui ribbon for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 9c0f61356c Refactor ui dialogs for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 260a629e9e Refactor ui canvas for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 1ecb22c67d Refactor render for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 36f6244cd4 Refactor proof for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor ffaeda9316 Refactor pdf for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 815d3f0032 Refactor mcp for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 937c7e532d Refactor layout for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 7976368de7 Refactor formats for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 0436139c57 Refactor fonts for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor a2435388e5 Refactor engine for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 75d30d69b4 Refactor docx for strict Clippy checks 2026-10-28 01:06:47 +00:00
Dylan M. Taylor 1d3e6d9ca8 Refactor doc for strict Clippy checks 2026-10-28 01:06:47 +00:00
aiongg d522bfd8b1 Merge pull request #4492 from Jeremy8776/feat/install-scope-policy
Windows installation policy: WordCraft
2026-10-27 17:08:58 -04:00
aiongg 0bd3420553 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:47 +00:00
Jeremy 54e1b1e7f3 fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:13:02 +00:00
aiongg 4b8b06fe03 Merge pull request #4602 from Silvertree2010/perf/export-speed
Speed up PDF export by about 24 %
2026-10-27 13:41:24 -04:00
NetterSilverandClaude Opus 5.5 de3bfaf049 Keep harfrust shape plans per face
Each shaping call let harfrust compile a new shape plan (the face's
lookups for the run's script, direction, language and features), which
took about as long as shaping a short run. Each thread now keeps up to 64
plans (the oldest dropped to make room), found by face and harfrust's
ShapePlanKey::matches, and passes the plan to the shaper. A kept plan is
only used for the face it was made for: the face id (from one process-wide
counter, so no two faces share it) together with the font bytes' address,
length and face index. The plan is the one the shaper would have made for
the buffer, so the glyphs are the same; tests shape with kept and fresh
plans and compare, and check the per-thread bound.

PDF export of the export_bench document (259 pages), interleaved with main
(A/A within 1 %): median 421 ms -> 319 ms (-24.2 %), measured on a loaded
machine; on a quieter run earlier 294 ms -> 224 ms (-23.8 %). PNG export of
page 1 (which lays out the whole document): about -34 %. The PDF and PNG
bytes are identical to main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:39:37 +01:00
aiongg f8e327b068 Merge pull request #3936 from benandez/fix/3907-section-marks
#3907: continuous sections running off a page go onto the next; empty section marks in a row stack to the page bottom
2026-10-27 12:37:46 -04:00
aiongg 50a8ebe307 Merge pull request #4387 from benandez/fix/3872-followup
#3872 follow-up: font table found by its stored spelling; privacy scrub keeps attribute values well-formed
2026-10-27 12:37:26 -04:00
aiongg f270dbd3b0 Merge pull request #4060 from benandez/fix/3995-thai-test-guard
#3995: Thai one-face fallback tests skip unless a Thai font with Latin is installed
2026-10-27 12:37:05 -04:00
Jeremy ffa35d62db fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:42 +00:00
aiongg 436d444ef0 fix(ui): dropdown menus grow back after a search with no matches (#4577)
* ui: craft_ui::ComboBox everywhere and the combo arrow popup on craft_ui::combo::Menu

Repin craft-libs to aiongg/craft-libs 62bb0e1d (dropdown regrow). Ban egui::ComboBox in clippy.toml.

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 6af63481 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)
2026-10-27 10:27:21 -04:00
NetterSilverandClaude Opus 5.5 ffa127c615 Skip per-character table lookups for plain text in layout and PDF export
Shortcuts that give the same answers as the lookups they skip:

- is_rtl: nothing before U+0590 is right to left, so Latin, Greek and
  Cyrillic text skips the bidi class search.
- strong_rtl and is_mark: ASCII letters are the only strong ASCII
  characters, and no ASCII character is a mark.
- cluster_starts: in ASCII text every character starts a grapheme except LF
  after CR, and no ASCII character joins a cluster.
- display lines: a glyph run's vectors and text are sized up front, and the
  symbol-font check is made once per run instead of once per cluster.
- PDF glyph runs: the baseline (the y most glyphs share) is only worked
  out for italic runs, the only ones that use it, and a run on one baseline
  returns it without counting.

Tests check each shortcut against the full lookup (every code point for the
bidi classes, every ASCII pair for the clusters).

PDF export of the export_bench document, interleaved with main: median
293 ms -> 241 ms (-18 % with the previous commit). The PDF bytes are
identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:39:09 +01:00
NetterSilverandClaude Opus 5.5 8cdc172171 Lay out the paragraph after a kept heading once
A paragraph with keep with next measures the paragraph after it before it is
placed (next_first_line). That measurement called layout_para directly, so
every paragraph following a heading was laid out twice in each layout pass:
once to measure it and once, through the cache, when it was placed.

The measurement now goes through the layout cache under the key
para_labelled uses (Ctx::para_ahead) when its inputs are the same: no
proofing, no typed word, not inside a text box, no inline text boxes that
resize, the body's tracked-deletion view. Otherwise it is laid out on its
own as before. It counts nothing (list labels, equation numbers and the
page-total flag are restored), so pagination is unchanged.

Adds crates/engine/examples/export_bench.rs, which saves a long generated
document (the sample document 200 times, 259 pages) through io::save_bytes
and prints wall times:

  cargo run --release -p wordcraft-engine --example export_bench -- pdf 200 9

PDF export on that document, interleaved with main (Ryzen, 16 threads):
median 286 ms -> 256 ms (-10.6 %). The PDF bytes are identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:39:09 +01:00
Jeremy d603983a61 docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording 2026-10-27 12:25:21 +00:00
Jeremy 72b8818ea6 packaging(windows): ship one dual-scope MSI
The package is dual-scope, so the separate -windows-user MSI was the same package under a
second name. Build and policy-check the single plain-named MSI before signing, which is the
name every sibling app, the launcher and the shared windows-arm64 workflow use. Replace the
loop test with one that checks the MSI is policy-checked once, before it is signed.
2026-10-27 12:17:18 +00:00
Jeremy 3031870ce3 docs(installer): describe the per-user default install location and scope 2026-10-27 12:09:04 +00:00
Jeremy 1b0a59d4d2 fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:02:00 +00:00
Jeremy 8edd6cb8eb fix(installer): address scope and ARM64 review feedback 2026-10-27 12:01:59 +00:00
Jeremy 89a059fb2d feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:01:59 +00:00
Martin BallandClaude Opus 5.5 1b485e1f90 #3907: CodeRabbit — a kept paragraph is measured against the next page's body (a different first-page header)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:24:50 +00:00
Martin BallandClaude Opus 5.5 751eb4b204 #3907: CodeRabbit — a stacked mark past the page bottom draws no line number either
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:39:26 +00:00
Martin BallandClaude Opus 5.5 5225569d44 #3907: review — an empty mark taking no room, or stacked past the page bottom, draws no list label; an empty mark after a text paragraph closing a section stays quiet
dpkruse's Genuine Steps Certificate: a bulleted text paragraph closes a
two-column continuous section, then an empty bulleted one closes another.
Word draws no bullet for the empty one; it is now quiet (takes no room) and
draws no label, still counted. A stacked mark that doesn't fit keeps its
line for the caret but draws nothing below the margin (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:04:05 +00:00
Martin BallandClaude Opus 5.5 f2fe7654e9 #3907: review — a kept paragraph low in a continuous section moves to the next page; a page break before a stacked mark keeps the new page's bottom
CodeRabbit (local): keep-lines compared its height with the room below the
mid-page section start, so a paragraph that fits on a new page split instead.
It now compares with the next column, or the next page's body when this is the
last column. A stacked mark whose page break before starts a page no longer
gets the previous page's bottom back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:56:44 +00:00
Martin BallandClaude Opus 5.5 7757828295 #3907: continuous sections running off a page go onto the next; empty marks in a row stack to the page bottom
A continuous section moves the column top down below the text above. at_top()
then held at every new section start, so a paragraph that did not fit was
placed anyway, as on an empty page. A run of one-line continuous sections ran
off the bottom of page 1 (100 of them reached 2,480 pt down a 792 pt page).
The overflow checks for paragraphs now use fresh(): at the top with nothing
above on this page. at_top() still drives the spacing rules, unchanged.

Empty paragraphs that close a continuous section right after another
section's mark took no room. Measured in Word on synthetic files ("Start", N
such marks, "End"): 10 put "End" about 9 cm down page 1; 50, 100 and 200 all
put it on the first line of page 2, the same with 24 pt marks or no paragraph
spacing. So each takes its line while it fits, then stays at the page's
bottom. A mark right after text stays quiet, as measured before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:56:35 +00:00
Martin BallandClaude Opus 5.5 04ebc4ed42 #3995: Thai one-face fallback tests skip unless a Thai font with Latin is installed
Noto Sans Thai and its siblings have no Latin letters or digits, so on a
machine whose only Thai fonts are Noto no face covers the test run and
fallback_for_run rightly returns None. The guard now asks whether the Thai
substitute covers the whole run, which doesn't go through fallback_for_run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:52:32 +00:00
Martin BallandClaude Opus 5.5 cd683a9fd1 #3872: review — privacy rewrite keeps attribute values as written, escaping only quotes
Unescaping and escaping again turned &#10; and &#9; into raw characters that read
back as spaces. The raw value of well-formed markup holds no `<` and every `&` starts
a reference, so only a `"` from a single-quoted value is replaced with &quot;.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-27 09:51:52 +00:00
Martin BallandClaude Opus 5.5 932a2e8955 #3872: review — font table found by its stored spelling; privacy rewrite escapes attribute values
- A package storing WORD/FONTTABLE.XML (part names are case-insensitive) loaded no
  embedded fonts and a save dropped the table and every font: lookups used the exact
  constant spelling. They now find the key case-insensitively.
- scrub_private re-pushed attribute values raw (quick-xml push_attribute does not
  escape), so a value read from single quotes became malformed XML. The value is now
  unescaped and escaped again.
- rel_targets matches _rels/ case-insensitively.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-27 09:51:52 +00:00
347 changed files with 13271 additions and 8612 deletions
+24
View File
@@ -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')
+4 -2
View File
@@ -26,8 +26,10 @@ permissions:
jobs:
windows-arm64:
# 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/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: wordcraft
app-dir: WordCraft
install-relative-path: Programs/ArtCraft/WordCraft
install-scope: perUser
+1 -1
View File
@@ -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.
Generated
+28 -28
View File
@@ -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,7 +1084,7 @@ 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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"chrono",
"dunce",
@@ -1109,7 +1109,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",
@@ -2854,7 +2854,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 +6647,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"arboard",
"chrono",
@@ -6682,7 +6682,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -6695,11 +6695,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 +6713,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chrono",
@@ -6730,7 +6730,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6754,7 +6754,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chrono",
@@ -6785,7 +6785,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6799,7 +6799,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chardetng",
@@ -6817,7 +6817,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"serde",
@@ -6825,7 +6825,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"js-sys",
@@ -6845,7 +6845,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"serde_json",
@@ -6857,7 +6857,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -6865,7 +6865,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -6886,7 +6886,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"miniz_oxide 0.9.1",
@@ -6896,7 +6896,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"image",
"log",
@@ -6910,7 +6910,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"cfb",
"chrono",
@@ -6941,7 +6941,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"eframe",
"egui",
@@ -6960,7 +6960,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"log",
"proptest",
@@ -7123,7 +7123,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.0"
dependencies = [
"serde_json",
]
+4 -4
View File
@@ -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,8 @@ 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 = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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.
@@ -122,7 +122,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" }
+10 -9
View File
@@ -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
+2
View File
@@ -172,6 +172,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed.
- 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891).
- 2026-10-22: Markdown inline code replaces each line ending with a space before export, including code in lists and table cells. A following heading, list marker or HTML tag stays literal code (#3738).
- 2026-10-23: Layout: paragraphs that each start a continuous section now go onto new pages. A run of them ran off the bottom of the first page, because each new section counted as the top of a page. Empty section-break paragraphs after the first in a row take a line each until the page is full and then stay at its bottom, so the text after them starts the next page, as measured in Word (#3907).
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs.
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
@@ -678,6 +679,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password |
| 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) |
| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged |
| 2026-10-23 | trivial | Progress log: continuous sections paginate, #3907 |
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
+23 -11
View File
@@ -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).
+1 -1
View File
@@ -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.
+3
View File
@@ -2,4 +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
+1 -1
View File
@@ -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)]
+21
View File
@@ -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) {
@@ -188,6 +193,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 រ.
+1 -1
View File
@@ -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))
+3 -1
View File
@@ -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;
+62 -57
View File
@@ -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
View File
@@ -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::*;
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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)]
+33 -7
View File
@@ -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")]
@@ -447,24 +447,50 @@ 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_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_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_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_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 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// An sRGB colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
+18 -5
View File
@@ -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 -1
View File
@@ -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")]
+1 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Rgb, Serialize, math, section, styles};
/// A bibliography source.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
+1 -1
View File
@@ -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 {
+9 -4
View File
@@ -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)`).
+11 -1
View File
@@ -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") {
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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> {
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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 };
+4 -1
View File
@@ -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> {
+4 -1
View File
@@ -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
+5 -1
View File
@@ -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`…).
+5 -1
View File
@@ -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 -1
View File
@@ -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
+8 -7
View File
@@ -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);
}
}
}
+17 -7
View File
@@ -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 };
+4 -1
View File
@@ -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)] = &[
+1 -1
View File
@@ -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
+65 -35
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, W};
fn to_source_type(kind: &str) -> &str {
match kind.to_ascii_lowercase().as_str() {
+22 -4
View File
@@ -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 `&#10;` 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"&quot;");
} else {
v.push(c);
}
}
Cow::Owned(v)
} else {
Cow::Borrowed(raw)
};
el.push_attribute(Attribute { key: a.key, value });
continue;
}
};
+63 -57
View File
@@ -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", &[]);
+175 -155
View File
@@ -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,172 @@ 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"),
],
);
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(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)>,
+46 -26
View File
@@ -253,6 +253,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 +329,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 +353,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 +365,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 +400,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 +432,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]) {
+25 -11
View File
@@ -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?
+1 -1
View File
@@ -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) {
+18 -16
View File
@@ -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")]);
+11 -2
View File
@@ -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;
+235 -221
View File
@@ -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 {
+3 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 {
+23 -12
View File
@@ -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);
+13 -1
View File
@@ -495,6 +495,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 +517,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 +534,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"));
+50
View File
@@ -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" &amp; &lt;go&gt;'/><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() {
// `&#10;` and `&#9;` 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&#10;line2&#9;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&#10;line2&#9;end""#), "{g}");
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
}
+40
View File
@@ -403,3 +403,43 @@ 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);
}
+74 -40
View File
@@ -2262,9 +2262,13 @@ 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);
}
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 +2285,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 +2298,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 +2319,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]
+64
View File
@@ -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, 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");
+4 -1
View File
@@ -2,7 +2,10 @@ use super::cross_refs::caption_label;
use super::render::entry;
use super::sources::bib_order;
use super::styles::style;
use super::*;
use super::{
Block, CharProps, CmdError, CmdResult, 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
+1 -1
View File
@@ -1,5 +1,5 @@
use super::Source;
use super::styles::{Cite, Name, Names, style};
use super::*;
pub(super) fn authors(a: &str) -> Vec<(String, String)> {
a.split(';')
+1 -1
View File
@@ -1,7 +1,7 @@
use super::generated::update_generated;
use super::render::{authors, cite};
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.
+2 -1
View File
@@ -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;
+15 -16
View File
@@ -32,6 +32,12 @@ fn records(id: &str) -> bool {
/// Tables nested deeper than this aren't compared (hostile documents).
const MAX_DEPTH: usize = 16;
/// A paragraph's, table's or cell's tracked formatting change, if it carries one.
type Slot<T> = Option<Box<PropChange<T>>>;
/// What a [`Slot`] should become (which may be no change at all).
struct Replacement<T>(Slot<T>);
struct Recorder<'a> {
author: String,
date: String,
@@ -64,14 +70,7 @@ impl Recorder<'_> {
/// The tracked change `after` should carry, given the props `before` the command (`f` strips
/// everything that isn't formatting); `None` when it already carries the right one.
#[allow(clippy::type_complexity, clippy::option_option)]
fn change<T: Clone + PartialEq>(
&mut self,
before: &T,
after: &T,
f: fn(&T) -> T,
get: fn(&T) -> &Option<Box<PropChange<T>>>,
) -> Option<Option<Box<PropChange<T>>>> {
fn change<T: Clone + PartialEq>(&mut self, before: &T, after: &T, f: fn(&T) -> T, get: fn(&T) -> &Slot<T>) -> Option<Replacement<T>> {
let (fb, fa) = (f(before), f(after));
let (had, has) = (get(before), get(after));
let want = if fb == fa {
@@ -92,7 +91,7 @@ impl Recorder<'_> {
PropChange::boxed(rev, old)
}
};
(want != *has).then_some(want)
(want != *has).then_some(Replacement(want))
}
/// Record the formatting changes between `b` and `a` (the same text) into `a`.
@@ -108,7 +107,7 @@ impl Recorder<'_> {
&& !own_insert
&& let Some(want) = self.change(*pb, *pa, CharProps::formatting, |c| &c.fmt_change)
{
runs.push((from, to, want));
runs.push((from, to, want.0));
}
if x.end <= y.end {
i += 1;
@@ -125,10 +124,10 @@ impl Recorder<'_> {
for (from, to, want) in runs {
let _ = a.format(from, to, &|c| c.fmt_change = want.clone());
}
if let Some(m) = mark {
if let Some(Replacement(m)) = mark {
a.mark.fmt_change = m;
}
if let Some(p) = props {
if let Some(Replacement(p)) = props {
a.props.fmt_change = p;
}
a.normalize();
@@ -149,18 +148,18 @@ impl Recorder<'_> {
self.para(pb, pa);
// The section's properties on the paragraph (`w:sectPrChange`).
let (Some(b), Some(x)) = (pb.section.as_deref(), pa.section.as_deref_mut()) else { continue };
if let Some(w) = self.change(b, x, strip_section, |s| &s.fmt_change) {
if let Some(Replacement(w)) = self.change(b, x, strip_section, |s| &s.fmt_change) {
x.fmt_change = w;
}
}
}
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() && tb.grid == ta.grid => {
let Block::Table(ta) = Arc::make_mut(a) else { continue };
if let Some(w) = self.change(&tb.props, &ta.props, strip_table, |t| &t.fmt_change) {
if let Some(Replacement(w)) = self.change(&tb.props, &ta.props, strip_table, |t| &t.fmt_change) {
ta.props.fmt_change = w;
}
for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) {
if let Some(w) = self.change(&rb.props, &ra.props, strip_row, |r| &r.fmt_change) {
if let Some(Replacement(w)) = self.change(&rb.props, &ra.props, strip_row, |r| &r.fmt_change) {
ra.props.fmt_change = w;
}
if rb.cells.len() != ra.cells.len() {
@@ -168,7 +167,7 @@ impl Recorder<'_> {
}
for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) {
// Merges are structure, not formatting: span and vertical merge stay out.
if let Some(w) = self.change(&cb.props, &ca.props, strip_cell, |c| &c.fmt_change) {
if let Some(Replacement(w)) = self.change(&cb.props, &ca.props, strip_cell, |c| &c.fmt_change) {
ca.props.fmt_change = w;
}
self.blocks(&cb.blocks, &mut ca.blocks, depth + 1);
@@ -1,4 +1,7 @@
use super::*;
use super::{
Align, Block, Border, Borders, CharProps, CmdError, CmdResult, InlineObject, Paragraph, PartKind, Selection, Session, StoryRef, Table, Value,
json, p, para_block,
};
/// The header (or footer) of the caret's section in the slot the caret's page uses (first page,
/// even page or the default), made when there is none: with Different First Page on, a header
+4 -1
View File
@@ -1,5 +1,8 @@
use super::header_footer::hf_part;
use super::*;
use super::{
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
delete_selection, json, p, para_block, sel_result, type_text,
};
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CharProps, CmdError, CmdResult, InlineObject, Pos, Selection, Session, StoryRef, Value, json, p, sel_result};
/// What a link can point to inside the document (Insert › Link › Place in This Document): the top
/// of the document, its headings (body block, level, text) and its bookmarks, without the hidden
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Block, CharProps, CmdError, CmdResult, Float, InlineObject, Paragraph, PartKind, Path, Pos, Rgb, Selection, Session, ShapeKind, StoryRef, Table,
Value, Wrap, delete_selection, json, p, para_block, sel_result,
};
/// Insert a block after the caret's paragraph (splitting it if the caret is mid-paragraph).
fn insert_block_at_caret(s: &mut Session, block: Block) -> Result<Path, CmdError> {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::merge::refresh;
use super::recipients::cell;
use super::*;
use super::{CmdError, CmdResult, MergeState, Session, Value, json, p};
/// A Filter Recipients comparison.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+4 -1
View File
@@ -1,5 +1,8 @@
use super::merge::address_block;
use super::*;
use super::{
Align, Block, CharProps, CmdError, CmdResult, Document, Paragraph, Pos, SectionProps, Selection, Session, StoryRef, Table, Value, json, p,
para_block,
};
/// Common envelope sizes: name, width and height in points (long side first).
pub const ENVELOPE_SIZES: [(&str, f32, f32); 8] = [
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value,
json, p, para_block,
};
pub(super) fn step(s: &mut Session, d: i64) -> CmdResult {
go_to(s, (s.merge.record as i64).saturating_add(d))
+1 -1
View File
@@ -1,5 +1,5 @@
use super::filter::refresh_shown;
use super::*;
use super::{CmdError, CmdResult, Session, Value, json, p};
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::select::{edit_float, min_size, movable_story, obj_size, select_object, selected, with_obj};
use super::*;
use super::{CmdError, CmdResult, Float, InlineObject, Pos, Selection, Session, StoryRef, Value, Wrap, json, p};
/// Largest object offset or move, points.
pub(super) const MAX_OFFSET: f32 = 4000.0;
+1 -1
View File
@@ -1,6 +1,6 @@
use super::arrange::{MAX_OFFSET, move_object, unalign};
use super::select::{FROZEN, INK_GROUP, holds_ink, min_size, movable_story, object_ref, object_selection, picked_objects, selected, shown_members};
use super::*;
use super::{CmdError, CmdResult, Float, InlineObject, Pos, Selection, Session, Spin, Value, Wrap, json, sel_result};
/// Add objects to the selection (Shift+click): the first one is selected if no object is yet;
/// one already selected is taken out again.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::select::{obj_size, selected, with_obj};
use super::*;
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CmdError, CmdResult, Float, GraphicKind, InlineObject, Pos, Selection, Session, Spin, StoryRef, Value, Wrap, json, p};
pub(super) fn has_picture(s: &Session) -> Option<&'static str> {
match selected(s) {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::select::{all_objects, edit_obj, picked_objects, selected, selected_all};
use super::*;
use super::{CmdError, CmdResult, InlineObject, Pos, Rgb, Session, StoryRef, TextBody, Value, json, p};
pub(super) const NO_TEXT_BOX: &str = "select a text box first";
+1 -1
View File
@@ -6,7 +6,7 @@ use super::select::{
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::*;
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
pub fn specs() -> Vec<CommandSpec> {
vec![
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
/// Resolved paragraph props at the caret.
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::format::fmt;
use super::*;
use super::{CmdError, CmdResult, LineSpacing, ListKind, NumRef, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::lists::touch_all;
use super::*;
use super::{Block, CmdError, CmdResult, NumRef, ParaProps, Pos, Session, SortKey, Value, p, sel_result, sort_key};
/// Move Paragraph Up / Down (Alt+Shift+Up/Down, ⌃⇧↑/↓ on macOS): the paragraphs the selection
/// touches swap places with the paragraph above or below, in their own container (the story or
+1 -1
View File
@@ -1,6 +1,6 @@
use super::format::fmt;
use super::lists::touch_all;
use super::*;
use super::{CharProps, CmdError, CmdResult, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
pub(super) fn style_id(s: &Session, name: &str) -> Result<String, CmdError> {
s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`")))
+1 -1
View File
@@ -1,5 +1,5 @@
use super::format::fmt;
use super::*;
use super::{Border, BorderStyle, Borders, CmdError, CmdResult, ParaProps, Rgb, Session, TabAlign, TabLeader, TabStop, Value, p, sel_result};
/// Most tab stops a paragraph keeps.
const MAX_TABS: usize = 64;
+4 -1
View File
@@ -2,7 +2,10 @@ use super::properties::{
bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result,
};
use super::toc::{heading_style_level, update_toc};
use super::*;
use super::{
Block, CharProps, CmdError, CmdResult, FieldCode, InlineObject, NumFormat, Paragraph, Path, Pos, Selection, Session, StoryRef, Value, p,
sel_result,
};
/// The `\*` format switch value for a caption's number format (OOXML names work too).
pub fn seq_format(name: &str) -> &'static str {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
CharProps, CmdError, CmdResult, InlineObject, NoteKind, Paragraph, PartKind, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p,
para_block,
};
pub(super) fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
let text = p::str(v, "text").unwrap_or("").to_string();
@@ -1,4 +1,4 @@
use super::*;
use super::{FieldCode, InlineObject, NumFormat, Pos};
/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows.
pub(super) fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str {
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Block, CharProps, CmdError, CmdResult, Counters, FieldCode, InlineObject, LevelSuffix, NumFormat, OBJ, Paragraph, Path, Pos, Selection, Session,
StoryRef, TabAlign, TabLeader, TabStop, Value, delete_selection, p, sel_result,
};
/// A level range such as `1-3` (or one level, `2`), clamped to 1–9.
pub fn level_range(s: &str) -> Option<(u8, u8)> {
+5 -8
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, fmt_revisions, json, p, pos_json, sel_result};
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
@@ -27,15 +27,12 @@ fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, pro
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
/// the section break on it), `props` the paragraph's own formatting change.
#[allow(clippy::too_many_arguments)]
fn resolve_para(
s: &mut Session,
story: StoryRef,
path: &wordcraft_doc::Path,
from: usize,
to: usize,
mark: bool,
props: bool,
(from, to): (usize, usize),
(mark, props): (bool, bool),
accept: bool,
) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
@@ -187,7 +184,7 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
for st in stories {
for path in s.doc.para_paths(st).into_iter().rev() {
let len = s.doc.para(st, &path).map(|p| p.len()).unwrap_or(0);
resolve_para(s, st, &path, 0, len, true, true, accept)?;
resolve_para(s, st, &path, (0, len), (true, true), accept)?;
}
}
// Tables, rows, cells and sections.
@@ -302,7 +299,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
// A paragraph's mark is in the selection when the selection goes on past it.
let mark = path != b.path || caret_mark.as_ref() == Some(&path);
let props = (ranged && (path != b.path || to > from)) || caret_props.as_ref() == Some(&path);
resolve_para(s, a.story, &path, from, to, mark, props, accept)?;
resolve_para(s, a.story, &path, (from, to), (mark, props), accept)?;
if ranged {
resolve_cell(s, a.story, &path, accept)?;
}
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
BTreeMap, BTreeSet, CmdError, CmdResult, Comment, InlineObject, Paragraph, PartKind, Pos, Selection, Session, StoryRef, Value, json, now_iso, p,
para_block, pos_json, sel_result,
};
pub(super) fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
let text = p::str(v, "text").unwrap_or("").to_string();
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CmdError, CmdResult, Pos, Selection, Session, StoryRef, Value, json, p, pos_json};
pub(super) fn word_count(s: &mut Session, v: &Value) -> CmdResult {
if let Some(b) = p::bool(v, "includeTextBoxes") {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::touch_cells;
use super::*;
use super::{CmdError, CmdResult, Document, Paragraph, Path, Pos, Session, StoryRef, Table, sel_result};
pub(super) fn cell(s: &Session) -> Result<(Path, usize, usize), CmdError> {
s.sel.focus.path.cell().ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))
+1 -1
View File
@@ -1,5 +1,5 @@
use super::structure::{del_col, del_row};
use super::*;
use super::{CmdError, CmdResult, Paragraph, Path, Pos, RevisionKind, Selection, Session, StoryRef, Value, sel_result};
/// Backspace with whole cells selected (#1844): whole rows go like Delete Rows (every row: the
/// table, like Delete Table), whole columns like Delete Columns, with their undo and Track

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