mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 08:41:10 +00:00
Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f69b003fe1
174 files changed
+5781
-603
No files matched your search
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -19,6 +19,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -26,8 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# 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
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-dir: WordCraft
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **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.
|
||||
@@ -63,22 +63,24 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
+9
-9
@@ -59,14 +59,14 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/images/dark.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/images/backstage.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/images/layout.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
|
||||
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
|
||||
@@ -1,13 +1,13 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **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.
|
||||
@@ -62,4 +62,9 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
Generated
+27
-25
@@ -809,9 +809,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
version = "5.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
@@ -1084,13 +1084,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -1207,9 +1208,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.3"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -6647,7 +6648,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6682,7 +6683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6695,11 +6696,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6713,7 +6714,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6730,7 +6731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6754,7 +6755,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6785,7 +6786,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6799,7 +6800,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -6812,12 +6813,13 @@ dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-geom",
|
||||
"wordcraft-metafile",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6825,7 +6827,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -6845,7 +6847,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6857,7 +6859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6865,7 +6867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -6886,7 +6888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -6896,7 +6898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6910,7 +6912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6941,7 +6943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6960,7 +6962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7123,7 +7125,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,7 +59,8 @@ 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 = "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"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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.
|
||||
|
||||
+69
-6
@@ -4,8 +4,8 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: 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); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchorLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
@@ -140,7 +140,28 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-31: DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word, not with the document defaults alone, so a Pandoc footer naming an undefined `Footer` style is as tall as Word's and no longer pushes the last paragraph to the next page (#5077). Readiness unchanged.
|
||||
- 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097).
|
||||
- 2026-10-31: Keytips reach the Backstage: Alt, F badges its pages with Word's letters, and Alt, F, P (or V) opens Print (#4960).
|
||||
- 2026-10-29: Documents with hyperlinks or a table of contents print again on the desktop (#4734). The print job scales each page to the paper, and the spooler refused a page with link annotations ("flatten page annotations before using shrink-to-fit"); the PDF sent to the printer now omits link annotations, which print nothing; the link and table-of-contents text stays on the page. Exported PDFs and Save these pages as PDF (`file.printPdf`) keep the annotations.
|
||||
- 2026-10-28: Word count counts each Chinese and Japanese character as a word, as Word does, in the status bar, the Word Count dialog and NUMWORDS fields. Unspaced Chinese text used to count as one word per run of text; Korean, which is written with spaces, still counts by words. (#4547)
|
||||
- 2026-10-28: Aligned-copy review fixes keep the document axis locked through middle-button scrolling and let Ctrl/Command+Shift start on a secondary selected object. Plain Ctrl and Shift clicks keep their existing behavior (#4764).
|
||||
|
||||
- 2026-10-28: Ctrl/Command+Shift copies a selected floating picture, shape or text box along its dominant axis (#4764); inline copies keep the text cursor. Guides on the free axis, original/clipboard/selection, cancel, protection and history are covered by synthetic Linux tests. Native desktop and Word untested; readiness unchanged.
|
||||
|
||||
- 2026-10-28: Charts and SmartArt draw text their font lacks in a fallback face, as body text does: Chinese titles, axis labels and legends no longer show boxes (#4720). A run of such characters takes one face that covers it all, so a label doesn't mix serif and sans glyphs; bold labels stay bold.
|
||||
- 2026-10-28: Custom document properties retain their bookmark link targets through DOCX saves, native JSON reopens, value edits and dialog renames (#4767). Linked values are not evaluated; the existing dialog and field-update behavior are unchanged. Original fixtures cover ordinary and raw property values; broader corpus qualification and readiness estimates remain unchanged.
|
||||
|
||||
- 2026-10-29: Increase/Decrease Font Size and the one-point shortcuts change each selected run from its own resolved size, retaining mixed-size formatting, inherited styles and one-step Undo (#4903). Existing size steps and limits stay unchanged.
|
||||
|
||||
- 2026-10-28: Convert Notes walks reference offsets and formatting runs together instead of rescanning all references for every run (#4750). Note text, IDs, custom marks, styles, history and saved content retain their behavior. Synthetic scaling, CLI/MCP and native/DOCX reopen coverage; readiness estimates unchanged.
|
||||
|
||||
- 2026-10-29: Saving a DOCX with many comments no longer searches all comment paragraph IDs and all reserved durable IDs for each comment. Paragraph IDs use their existing sorted order; durable-ID reservations are indexed once. Parent links, resolved flags, authored text and existing ID rules are unchanged.
|
||||
|
||||
- 2026-10-28: RTF import keeps pictures stored as Windows Metafiles (`\wmetafile8`, as Word writes a pasted or floating picture) and Enhanced Metafiles (`\emfblip`); they were dropped, so such a document opened without its pictures. They are drawn by the metafile renderer, saved to .docx as `.wmf`/`.emf` media, and saved to .rtf as metafiles again (RTF export dropped them too). (#4259)
|
||||
- 2026-10-27: File › Options › Proofing › AutoCorrect Options opens with no document open (#4585). On the Start screen, or after closing the last document, the button did nothing: AutoCorrect wasn't among the commands allowed without a document. Its options are user preferences, so changes made there are kept for the next document.
|
||||
- 2026-10-29: Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Labels → New Document with one label per recipient includes every selected address, with unused labels blank on the final sheet. Fixed-text and single-label output remain one sheet. Finish & Merge template expansion is separate.
|
||||
- 2026-10-29: HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged.
|
||||
- 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document.
|
||||
- 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window.
|
||||
- 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them.
|
||||
@@ -202,7 +223,15 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
| 2026-10-28 | **Arabic diacritic editing (#4757):** Backspace removes the last stored Arabic combining mark, so its base letter can stay while the mark is replaced. Caret movement and forward Delete keep their cluster rules. Synthetic command, history, protection and native/DOCX fresh-reopen checks; no native Word comparison or readiness change. |
|
||||
| 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) |
|
||||
| 2026-10-29 | Window sizing falls back to the screen rectangle when the reported inner size is invalid, and an Equation symbol tooltip no longer repeats a missing glyph's name or code point |
|
||||
| 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged |
|
||||
| 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. |
|
||||
| 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. |
|
||||
| 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) |
|
||||
| 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged |
|
||||
| 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). |
|
||||
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
|
||||
| 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only |
|
||||
| 2026-10-25 | #4085 mark review: whole-cell and ordinary highlight targets include their selected paragraph marks; empty, inherited and partial-column controls added. |
|
||||
@@ -256,6 +285,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-16 | DOCX: a `HYPERLINK` field (complex or simple) reads as a link, as a `w:hyperlink` does: its result stays ordinary text carrying the link (`url`, `url#bookmark` or `#bookmark`), so a tab in it reaches its tab stop and a `PAGEREF` in it stays a field (#2174). A table of contents from Word for the web, whose entries are such fields, showed a box for each tab and no dot leaders; the entries now open as in Word and their links work |
|
||||
| 2026-10-17 | The document's right-click menu offers the ribbon's Paste menu, item for item (#2583): Paste (the clipboard's formatting kept, Word's Keep Source Formatting), Keep Text Only, Merge Formatting and Paste Special…, where it had a single Paste. Same commands and shortcuts, with the system clipboard |
|
||||
| 2026-10-17 | SmartArt Text Pane: Tab demotes and Shift+Tab promotes the focused item, as Word's pane does (#2701). The row keeps its focus, so a second Tab acts on the same item; an item that can't go down a level is left alone |
|
||||
| 2026-10-15 | Selected table rows and cells copy as a table (#1744): Copy of whole rows, of cells in a row, or of a block of cells over several rows, then Paste outside the table, inserts a table of just those cells; Cut of rows or cells clears their text and leaves the rows and cells in place (the clipboard still gets the table), as Word does. Copy used to take only the cells' paragraphs |
|
||||
| 2026-10-14 | Tables can be copied and cut (#1744): with the whole table selected (Select Table, or a drag over all of it), Copy puts the table itself on the clipboard, so Paste outside it inserts a copy of the table with its formatting (the plain text is tab and newline separated); Cut removes the table in one undo step, as in Word. Copy used to keep only the cells' paragraphs and Cut left the table in place |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-19 | Typing no longer starts render threads on every repaint (#3027): page, area and icon renders share one kept vello_cpu context, reset to each size, instead of building a multi-threaded one (and its thread pool) per render. Typing about 150 characters in 15 s created 295–388 threads before and none now; CPU time while typing fell by about a third (Windows, debug build). |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
@@ -625,7 +656,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) |
|
||||
| 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) |
|
||||
| 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released; file associations on Windows and Linux with a Make-default button in Options (#295) |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs restructured (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released; file associations on Windows and Linux with a Make-default button in Options (#295) |
|
||||
| 2026-10-09 | Interface languages follow the system or Options (#12; zh-hans, zh-hant, ja); TOC page numbers as an updatable field (#52); rotating log file (#17); window geometry remembered (#38) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released; Flatpak and AppImage auto-update (#22) |
|
||||
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
|
||||
@@ -637,15 +668,38 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-15 | The web app keeps its preferences between visits (#2132): theme (dark mode), interface size, editing options and the rest of what the desktop app saves in `ui.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-17 | Saving into a folder that refuses a temporary file (macOS privacy protection, read-only folders, #2138) never overwrites an existing document in place: it is left untouched and the error says to use Save As to another folder or allow WordCraft access to the folder (System Settings › Privacy & Security › Files and Folders on macOS). A new file is created directly. Unit tests on Linux; not yet confirmed on a Mac |
|
||||
| 2026-10-20 | Saving to a NAS share mounted by GVFS (`smb://` in the Linux file manager, #3003) works: the share refuses every Unix mode change with "Operation not supported", which used to fail the whole save, so the close prompt's Save never closed. The save now goes on without copying a mode the folder doesn't keep; any other failure still keeps the original. Unit tests; not yet confirmed on a real share |
|
||||
| 2026-10-29 | Corporate authors in bibliographies (#4848): a source whose author is an organisation (Word's Corporate Author, `b:Corporate`) keeps it when a Word document opens, instead of losing the author. Citations and bibliography entries write the name as it is in every style ("(World Health Organization, 2021)", never "Organization, W. H."); it gives the source's tag and its place in an author-sorted list, and it saves back as `b:Corporate`. Create Source has a Corporate Author box under Author. |
|
||||
| 2026-10-29 | Table Properties follows the selection (#4857): with the whole table (or several rows, columns or cells) selected, the Row, Column and Cell settings apply to every selected row, column and cell, as in Word. Before, they changed the caret's row alone, so unticking Allow row to break across pages with the table selected left the other rows breaking. Allow row to break and Repeat as header row show as mixed when the selected rows differ and change nothing unless clicked. |
|
||||
|
||||
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
|
||||
|
||||
## Revision history
|
||||
|
||||
> 2026-10-28 · Markdown import bounds reference-label scans and normalization within the existing link-work allowance. Nested undefined labels preserve literal text instead of causing quadratic work (#4658).
|
||||
>
|
||||
> 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) |
|
||||
| 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) |
|
||||
| 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) |
|
||||
| 2026-10-30 | trivial | Progress log: only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them (#4734) |
|
||||
| 2026-10-29 | trivial | Progress log: documents with links print (#4734) |
|
||||
| 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) |
|
||||
| 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged |
|
||||
| 2026-10-27 | trivial | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Preserve custom-property bookmark link targets through saves, value edits and dialog renames (#4767); no linked-value evaluation or readiness change |
|
||||
| 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Note conversion walks reference offsets and formatting runs together, #4750; readiness estimates unchanged. |
|
||||
| 2026-10-29 | trivial | DOCX comment IDs use ordered paragraph lookup and one durable-ID reservation set; threading and ID rules unchanged |
|
||||
| 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching |
|
||||
| 2026-10-29 | trivial | Progress log: Insert › Shapes icon grid (#4349) |
|
||||
| 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. |
|
||||
| 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips |
|
||||
| 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) |
|
||||
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
|
||||
| 2026-10-22 | trivial | Progress log: one native window per open document |
|
||||
| 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy |
|
||||
@@ -751,6 +805,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-18 | trivial | Progress log: header-cell picture watermarks drawn behind the text (#1775); readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: comment drafts kept and written before saving or closing (#1348) |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
| 2026-10-17 | trivial | Progress log: macOS launch arguments and open failures (#1103) |
|
||||
| 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) |
|
||||
| 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) |
|
||||
| 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) |
|
||||
@@ -804,6 +859,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-16 | trivial | Progress log: a text frame in the paragraph defaults frames nothing (#2360) |
|
||||
| 2026-10-16 | trivial | Progress log: a text file saved over itself closes without asking (#2376) |
|
||||
| 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) |
|
||||
| 2026-10-15 | trivial | Progress log: selected table rows and cells copy as a table; Cut clears their text (#1744) |
|
||||
| 2026-10-14 | trivial | Progress log: Select Table copies and cuts the whole table (#1744) |
|
||||
| 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
@@ -992,10 +1049,10 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
|
||||
| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% |
|
||||
| 2026-10-10 | minor | Merged main: catalog 384/430 (89.3%), 431 commands; Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info, Serbian landed; mainstream 54→55% |
|
||||
| 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): method aligned with the standard, no new evidence; beta distance back to ~15 points and ~150–230 h |
|
||||
| 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the standard's discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha |
|
||||
| 2026-10-10 | minor | Readiness table with hours per audience; full number restored to the additive weighted sum (~47% → ~60%): no new evidence; beta distance back to ~15 points and ~150–230 h |
|
||||
| 2026-10-10 | minor | Mainstream practitioner (~54%) and essentials user (~63%) numbers added; full number recomputed 60% → ~47% with the discounts; beta distance now ~28 points and ~450–700 h; stage stays alpha |
|
||||
| 2026-10-10 | minor | Alpha gate checked (six core workflows pass); stage stays alpha |
|
||||
| 2026-10-10 | major | Full re-measure; restructured to the progress-docs standard (stage banner, two numbers, dimensions, languages, upcoming, progress log); parity tables moved to `docs/target-app-parity.md`, milestones to `docs/roadmap.md` |
|
||||
| 2026-10-10 | major | Full re-measure; progress docs restructured (stage banner, two numbers, dimensions, languages, upcoming, progress log); parity tables moved to `docs/target-app-parity.md`, milestones to `docs/roadmap.md` |
|
||||
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
|
||||
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
|
||||
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
|
||||
@@ -1009,3 +1066,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-23 | trivial | Progress log: justified lines ending in a manual line break reach the margin (#3924) |
|
||||
| 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) |
|
||||
| 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) |
|
||||
| 2026-10-28 | trivial | Progress log: chart and SmartArt text the font lacks is drawn in a fallback face (#4720) |
|
||||
| 2026-10-29 | trivial | Progress log: corporate authors in bibliographies (#4848) |
|
||||
| 2026-10-29 | trivial | Progress log: Table Properties follows the selection (#4857) |
|
||||
| 2026-10-27 | trivial | Progress log: AutoCorrect Options with no document open (#4585) |
|
||||
| 2026-10-28 | trivial | Progress log: RTF metafile pictures are imported (#4259) |
|
||||
| 2026-10-28 | trivial | Progress log: Chinese and Japanese text counts a word per character (#4547) |
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security problems privately through GitHub:
|
||||
**[Report a vulnerability](https://github.com/storytold/wordcraft/security/advisories/new)**
|
||||
(Security tab › Advisories › Report a vulnerability).
|
||||
|
||||
Don't open a public issue or pull request for a vulnerability until a fix is released.
|
||||
|
||||
Useful to include:
|
||||
- the WordCraft version or commit, and the platform (macOS, Windows, Linux, BSD, web);
|
||||
- what an attacker can do, and what they need (a crafted document, MCP or control-channel access, a local account…);
|
||||
- steps or a sample file that reproduce it.
|
||||
|
||||
We'll acknowledge the report, keep you updated while we work on a fix, and credit you in the advisory unless you'd rather not be named.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes go into `main` and ship in the next release. Older releases don't get backported fixes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: anything that lets a document, file, command, MCP or control-channel request, or network response read or write files it shouldn't, run code, leak data, or crash WordCraft and lose the user's work.
|
||||
|
||||
Crashes from hostile input count, because WordCraft treats every panic as a bug (see [Never crash](AGENTS.md)).
|
||||
@@ -49,9 +49,9 @@ impl PrefsSync {
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let mut prefs = app.prefs();
|
||||
if let Some((host, own)) = &self.host_author
|
||||
&& prefs.author == *host
|
||||
&& prefs.author.as_deref() == Some(host.as_str())
|
||||
{
|
||||
prefs.author = own.clone();
|
||||
prefs.author = Some(own.clone());
|
||||
}
|
||||
let json = match serde_json::to_string(&prefs) {
|
||||
Ok(j) => j,
|
||||
|
||||
@@ -47,10 +47,9 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
log::warn!("{p}: {e}");
|
||||
let e = e.to_string();
|
||||
let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}");
|
||||
app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)]));
|
||||
app.report_open_failure(p, &e);
|
||||
// Backstage hides the status bar, so leave it to show the error.
|
||||
app.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,26 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
(args, files)
|
||||
}
|
||||
|
||||
/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and
|
||||
/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with
|
||||
/// the value after each. They are not files, and opening them as files fails.
|
||||
fn without_launch_args(args: Vec<String>) -> Vec<String> {
|
||||
let mut kept = Vec::with_capacity(args.len());
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
if a.starts_with("-psn_") {
|
||||
continue;
|
||||
}
|
||||
if a.starts_with("-NS") || a.starts_with("-Apple") {
|
||||
// Its value, unless the next argument is another flag.
|
||||
args.next_if(|v| !v.starts_with('-'));
|
||||
continue;
|
||||
}
|
||||
kept.push(a);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
|
||||
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
|
||||
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
|
||||
@@ -340,7 +360,9 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("{name}: {e}"),
|
||||
Err(e) => {
|
||||
app.report_open_failure(&name, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +372,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
@@ -360,6 +382,8 @@ fn main() -> eframe::Result {
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let (args, other_files) = text_args();
|
||||
// Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name.
|
||||
let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args };
|
||||
let mut bad_control: Option<Option<String>> = None;
|
||||
let mut args = args.into_iter().peekable();
|
||||
while let Some(a) = args.next() {
|
||||
@@ -504,7 +528,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
log::warn!("{f}: {e}");
|
||||
app.report_open_failure(&f, &e);
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
@@ -590,6 +614,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_arg_tests {
|
||||
use super::without_launch_args;
|
||||
|
||||
fn strip(args: &[&str]) -> Vec<String> {
|
||||
without_launch_args(args.iter().map(|a| a.to_string()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_launch_arguments_are_not_files() {
|
||||
assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]);
|
||||
// A flag with no value after it, or followed by another flag, takes nothing with it.
|
||||
assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]);
|
||||
assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_arguments_are_kept() {
|
||||
assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]);
|
||||
// Files whose names merely start like a launch argument keep their place when they are paths.
|
||||
assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]);
|
||||
assert!(strip(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_tests {
|
||||
use super::write_prefs;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -181,3 +178,12 @@ sync_github_name = true
|
||||
|
||||
[people.pakiat]
|
||||
display_name = "OtorO"
|
||||
|
||||
[people.TobyWu666]
|
||||
display_name = "tobywu"
|
||||
|
||||
[people.Bra1nZ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
@@ -115,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
|
||||
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
|
||||
/// South and South-East Asia.
|
||||
/// Characters Backspace removes individually rather than with their whole cluster: Arabic
|
||||
/// combining marks and characters of the Brahmic scripts of South and South-East Asia.
|
||||
pub fn backspaces_by_char(c: char) -> bool {
|
||||
matches!(u32::from(c),
|
||||
0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0)
|
||||
| 0x064B..=0x065F
|
||||
| 0x0670
|
||||
| 0x06D6..=0x06DC
|
||||
| 0x06DF..=0x06E4
|
||||
| 0x06E7..=0x06E8
|
||||
| 0x06EA..=0x06ED
|
||||
| 0x0897..=0x089F
|
||||
| 0x08CA..=0x08E1
|
||||
| 0x08E3..=0x08FF
|
||||
| 0x10EFA..=0x10EFF
|
||||
| 0x0900..=0x0DFF // Devanagari … Sinhala
|
||||
| 0x0E00..=0x0EFF // Thai, Lao
|
||||
| 0x0F00..=0x0FFF // Tibetan
|
||||
| 0x1000..=0x109F // Myanmar
|
||||
@@ -250,8 +260,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_by_character_in_brahmic_scripts_only() {
|
||||
fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() {
|
||||
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
|
||||
assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}'));
|
||||
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
|
||||
assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}'));
|
||||
}
|
||||
}
|
||||
+26
-13
@@ -14,7 +14,7 @@
|
||||
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
|
||||
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -422,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
}
|
||||
|
||||
/// Positions of the markers without a partner in a block list and the cells inside it.
|
||||
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let events = control_events(bl);
|
||||
let (_, lone) = pair_events(&events);
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: count those before it on the same table.
|
||||
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
|
||||
tables.push((Path(path), nth, true));
|
||||
if !lone.is_empty() {
|
||||
let mut table_counts: HashMap<usize, usize> = HashMap::new();
|
||||
let mut open_nth = Vec::with_capacity(events.len());
|
||||
for e in &events {
|
||||
if let ControlEvent::Open { block, off: None, .. } = e {
|
||||
let count = table_counts.entry(*block).or_insert(0);
|
||||
open_nth.push(*count);
|
||||
*count += 1;
|
||||
} else {
|
||||
open_nth.push(0);
|
||||
}
|
||||
}
|
||||
for k in lone {
|
||||
let Some(e) = events.get(k) else { continue };
|
||||
let mut path = prefix.clone();
|
||||
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
|
||||
match (e, e.off()) {
|
||||
(_, Some(off)) => out.push((Path(path), off)),
|
||||
(ControlEvent::Open { .. }, None) => {
|
||||
// Which of the table's opens: index among table opens on the same table.
|
||||
let nth = open_nth.get(k).copied().unwrap_or(0);
|
||||
tables.push((Path(path), nth, true));
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
|
||||
}
|
||||
}
|
||||
for (i, b) in bl.iter().enumerate() {
|
||||
|
||||
@@ -443,8 +443,7 @@ impl Document {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
|
||||
/// including generated numbered-list labels).
|
||||
/// Count words like Word's status bar ([`crate::count_words`], plus generated numbered-list labels).
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.story_word_count(StoryRef::Body)
|
||||
}
|
||||
@@ -795,7 +794,9 @@ impl Document {
|
||||
p.kind = "lpwstr".into();
|
||||
p.value = value.to_string();
|
||||
}
|
||||
None => self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string() }),
|
||||
None => {
|
||||
self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-1
@@ -434,10 +434,16 @@ impl Document {
|
||||
at.extend(offs.get(k).copied());
|
||||
}
|
||||
}
|
||||
// Reference offsets and run boundaries are ordered; do not restart at the first
|
||||
// reference for every run, including runs after the last reference.
|
||||
let mut at = at.into_iter().peekable();
|
||||
let mut start = 0usize;
|
||||
for r in &mut p.runs {
|
||||
let end = start.saturating_add(r.len);
|
||||
if at.iter().any(|&o| o >= start && o < end) {
|
||||
while at.peek().is_some_and(|&o| o < start) {
|
||||
at.next();
|
||||
}
|
||||
if at.peek().is_some_and(|&o| o < end) {
|
||||
swap(&mut r.props.style);
|
||||
}
|
||||
start = end;
|
||||
@@ -792,6 +798,63 @@ impl Document {
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
|
||||
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
|
||||
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
|
||||
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
|
||||
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
|
||||
let t = self.table(story, tp)?.slice(rows, cols);
|
||||
if t.rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.fragment_of(vec![Block::Table(t)]))
|
||||
}
|
||||
|
||||
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
|
||||
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
|
||||
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
|
||||
/// outermost one covered whole (a table nested in a cell when the range covers just it).
|
||||
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
|
||||
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
|
||||
let (a, b) = order(a, b);
|
||||
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
|
||||
return None;
|
||||
}
|
||||
let mut paths: Option<Vec<Path>> = None;
|
||||
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
|
||||
// every three steps (block, row, cell) deeper.
|
||||
let mut n = 1usize;
|
||||
while n.saturating_add(3) <= a.path.0.len() {
|
||||
let pre = a.path.0.get(..n)?;
|
||||
if !b.path.0.starts_with(pre) {
|
||||
return None;
|
||||
}
|
||||
let tp = Path(pre.to_vec());
|
||||
if self.table(a.story, &tp).is_some() {
|
||||
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
|
||||
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
|
||||
if let Some(first) = inside.next() {
|
||||
let last = inside.next_back().unwrap_or(first);
|
||||
let end = self.para(a.story, last).map_or(0, Paragraph::len);
|
||||
if *first == a.path && *last == b.path && b.off >= end {
|
||||
return Some(tp);
|
||||
}
|
||||
}
|
||||
}
|
||||
n = n.saturating_add(3);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The whole body as a fragment (with its text boxes), to insert into another document.
|
||||
pub fn body_fragment(&self) -> Fragment {
|
||||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||||
@@ -846,6 +909,13 @@ impl Document {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
|
||||
if let Some(tp) = self.whole_table(&a, &b)
|
||||
&& let Some(blk) = self.block(a.story, &tp)
|
||||
{
|
||||
out.push(blk.clone());
|
||||
return out;
|
||||
}
|
||||
for p in self.paths_between(&a, &b) {
|
||||
if let Some(para) = self.para(a.story, &p) {
|
||||
let mut q = para.clone();
|
||||
|
||||
@@ -501,9 +501,8 @@ impl Paragraph {
|
||||
pub fn next_boundary(&self, off: usize) -> usize {
|
||||
crate::cluster::next_cluster(&self.text, self.clamp(off))
|
||||
}
|
||||
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
|
||||
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
|
||||
/// at a time, otherwise the previous caret stop.
|
||||
/// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the
|
||||
/// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop.
|
||||
pub fn backspace_from(&self, off: usize) -> usize {
|
||||
let off = self.clamp(off);
|
||||
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
|
||||
|
||||
@@ -273,6 +273,12 @@ pub struct CharProps {
|
||||
pub small_caps: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hidden: Option<bool>,
|
||||
/// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_hidden: Option<bool>,
|
||||
/// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub snap_to_grid: Option<bool>,
|
||||
/// Character spacing (expanded/condensed), points.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spacing: Option<f32>,
|
||||
@@ -448,6 +454,7 @@ impl CharProps {
|
||||
/// overlaid on `base`.
|
||||
pub fn minus(mut self, base: &CharProps) -> CharProps {
|
||||
self.minus_text_style(base);
|
||||
self.minus_run_visibility(base);
|
||||
self.minus_script_style(base);
|
||||
self.minus_document_marks(base);
|
||||
self
|
||||
@@ -458,6 +465,10 @@ impl CharProps {
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn minus_run_visibility(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn minus_script_style(&mut self, base: &CharProps) {
|
||||
drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
@@ -472,6 +483,7 @@ impl CharProps {
|
||||
/// format both scripts must set both values explicitly.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
self.overlay_text_style(patch);
|
||||
self.overlay_run_visibility(patch);
|
||||
self.overlay_script_style(patch);
|
||||
self.overlay_document_marks(patch);
|
||||
if let Some(p) = &patch.text_effects {
|
||||
@@ -484,6 +496,10 @@ impl CharProps {
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
|
||||
}
|
||||
|
||||
fn overlay_run_visibility(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; web_hidden, snap_to_grid);
|
||||
}
|
||||
|
||||
fn overlay_script_style(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
|
||||
}
|
||||
|
||||
@@ -270,7 +270,12 @@ impl ResolvedPara {
|
||||
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
|
||||
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
|
||||
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
|
||||
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
|
||||
// Explicit zero dimensions cancel a frame; omitted dimensions still fit its text.
|
||||
frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| {
|
||||
f.w = f.w.filter(|v| *v != 0.0);
|
||||
f.h = f.h.filter(|v| *v != 0.0);
|
||||
f
|
||||
}),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
@@ -295,8 +300,12 @@ impl StyleSheet {
|
||||
self.para_style_props_over(style, self.default_para.clone(), self.default_chr.clone())
|
||||
}
|
||||
|
||||
/// The paragraph style chain of `style` applied over `pp` / `cp`.
|
||||
/// The paragraph style chain of `style` applied over `pp` / `cp`. A style the document doesn't
|
||||
/// define is the default paragraph style, Normal, as in Word: a footer naming a `Footer` style
|
||||
/// that `styles.xml` leaves out is formatted as Normal, not with the document defaults alone
|
||||
/// (#5077).
|
||||
fn para_style_props_over(&self, style: &str, mut pp: ParaProps, mut cp: CharProps) -> (ParaProps, CharProps) {
|
||||
let style = if self.get(style).is_some() { style } else { "Normal" };
|
||||
for s in self.chain(style) {
|
||||
if s.kind == StyleKind::Paragraph {
|
||||
let mut sp = s.para.clone();
|
||||
@@ -395,6 +404,31 @@ mod tests {
|
||||
assert_eq!(s.resolve_char(None, &direct).font, "Wingdings");
|
||||
}
|
||||
|
||||
/// #5077: a paragraph naming a style the document doesn't define is formatted as Normal,
|
||||
/// paragraph and run alike, not with the document defaults alone.
|
||||
#[test]
|
||||
fn an_undefined_paragraph_style_is_normal() {
|
||||
let mut s = StyleSheet::builtin();
|
||||
s.default_para.space_after = Some(10.0);
|
||||
if let Some(n) = s.get_mut("Normal") {
|
||||
n.para.space_after = Some(2.0);
|
||||
n.chr.size = Some(13.0);
|
||||
}
|
||||
let undefined = ParaProps { style: Some("NoSuchStyle".into()), ..Default::default() };
|
||||
let normal = s.resolve_para(&ParaProps::default());
|
||||
let p = s.resolve_para(&undefined);
|
||||
assert_eq!(p.space_after, 2.0);
|
||||
assert_eq!(p.space_after, normal.space_after);
|
||||
assert_eq!(p.line_spacing, normal.line_spacing);
|
||||
assert_eq!(s.resolve_char(Some("NoSuchStyle"), &CharProps::default()).size, 13.0);
|
||||
// The paragraph keeps the style it names; direct formatting still wins.
|
||||
assert_eq!(p.style, "NoSuchStyle");
|
||||
let direct = ParaProps { space_after: Some(7.0), ..undefined };
|
||||
assert_eq!(s.resolve_para(&direct).space_after, 7.0);
|
||||
// A defined style is unchanged.
|
||||
assert_eq!(s.resolve_para(&ParaProps { style: Some("Heading1".into()), ..Default::default() }).space_after, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_resolves() {
|
||||
let s = StyleSheet::builtin();
|
||||
|
||||
@@ -10,6 +10,10 @@ pub struct Source {
|
||||
pub kind: String,
|
||||
/// "Last, First; Last, First"
|
||||
pub author: String,
|
||||
/// A corporate author ("World Health Organization"): written as it is, never split into
|
||||
/// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`,
|
||||
/// #4848).
|
||||
pub corporate: String,
|
||||
pub title: String,
|
||||
pub year: String,
|
||||
pub publisher: String,
|
||||
@@ -39,6 +43,9 @@ pub struct CustomProp {
|
||||
/// the property's XML content kept verbatim (vectors, blobs).
|
||||
pub kind: String,
|
||||
pub value: String,
|
||||
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link_target: Option<String>,
|
||||
}
|
||||
|
||||
/// Document properties (File › Info).
|
||||
|
||||
@@ -77,6 +77,80 @@ pub const MAX_COLS: usize = 63;
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
/// and whether any cell touches them (Cut clears the same cells it copies).
|
||||
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
|
||||
let ((r0, r1), (g0, g1)) = (rows, cols);
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
|
||||
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
|
||||
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
|
||||
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
|
||||
let mut any = false;
|
||||
for _ in 0..=MAX_COLS.saturating_add(1) {
|
||||
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g < hi && end > lo {
|
||||
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
}
|
||||
any = found;
|
||||
if (nlo, nhi) == (lo, hi) {
|
||||
break;
|
||||
}
|
||||
(lo, hi) = (nlo, nhi);
|
||||
}
|
||||
(lo, hi, any)
|
||||
}
|
||||
|
||||
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
|
||||
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
|
||||
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
|
||||
/// that continues a vertical merge from a row left out starts the merge instead.
|
||||
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
|
||||
let (r0, r1) = rows;
|
||||
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
|
||||
let (lo, hi, any) = self.slice_bounds(rows, cols);
|
||||
let mut out_rows = Vec::new();
|
||||
for row in kept() {
|
||||
let mut g = 0usize;
|
||||
let mut cells = Vec::new();
|
||||
for cell in &row.cells {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if any && g < hi && end > lo {
|
||||
cells.push(cell.clone());
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
if !cells.is_empty() {
|
||||
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
|
||||
}
|
||||
}
|
||||
if let Some(first) = out_rows.first_mut() {
|
||||
for c in &mut first.cells {
|
||||
if c.props.vmerge == VMerge::Continue {
|
||||
c.props.vmerge = VMerge::Restart;
|
||||
}
|
||||
}
|
||||
}
|
||||
let whole = lo == 0 && hi >= self.grid.len();
|
||||
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
|
||||
let mut props = self.props.clone();
|
||||
if !whole {
|
||||
// Part of the columns: as wide as they are, not the whole table's width.
|
||||
if props.width.is_some() || props.width_pct.is_some() {
|
||||
props.width = Some(grid.iter().sum());
|
||||
}
|
||||
props.width_pct = None;
|
||||
}
|
||||
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
|
||||
}
|
||||
|
||||
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
|
||||
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
|
||||
let rows = rows.clamp(1, MAX_ROWS);
|
||||
@@ -566,6 +640,69 @@ impl Table {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
|
||||
fn merged() -> Table {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
t.merge(0, 0, 0, 1);
|
||||
t.merge(1, 2, 2, 2);
|
||||
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
|
||||
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
t
|
||||
}
|
||||
|
||||
fn spans(t: &Table) -> Vec<Vec<usize>> {
|
||||
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
|
||||
}
|
||||
|
||||
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
|
||||
/// other rows widen with it, so the copy isn't ragged.
|
||||
#[test]
|
||||
fn slice_widens_to_a_merged_cell_in_every_row() {
|
||||
let t = merged().slice((0, 1), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
|
||||
// Rows without the merged cell: just the selected columns.
|
||||
let t = merged().slice((1, 2), (1, 2));
|
||||
assert_eq!(t.grid, [100.0, 100.0]);
|
||||
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
|
||||
}
|
||||
|
||||
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
|
||||
#[test]
|
||||
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
|
||||
let t = merged().slice((2, 2), (0, 2));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
|
||||
// Both rows kept: the merge is kept as it is.
|
||||
let t = merged().slice((1, 2), (2, 2));
|
||||
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
|
||||
}
|
||||
|
||||
/// #1744 review: part of the columns is as wide as they are; content controls around rows
|
||||
/// and the table aren't copied.
|
||||
#[test]
|
||||
fn slice_width_and_controls() {
|
||||
let mut t = Table::new(3, 3, 300.0);
|
||||
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
|
||||
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
|
||||
t.controls = wrap.clone();
|
||||
t.rows[1].controls = wrap;
|
||||
let part = t.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
|
||||
let whole = t.slice((0, 2), (0, 2));
|
||||
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
|
||||
let row = t.slice((1, 1), (0, 2));
|
||||
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
|
||||
// A table sized only by a percentage keeps a width for the part.
|
||||
let mut pct = Table::new(3, 3, 300.0);
|
||||
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
|
||||
let part = pct.slice((0, 2), (2, 2));
|
||||
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
|
||||
// Nothing in range: no rows.
|
||||
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
|
||||
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_before_shifts_the_row() {
|
||||
let mut t = Table::new(2, 3, 300.0);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
#[test]
|
||||
fn new_document_is_valid() {
|
||||
let d = Document::new();
|
||||
@@ -16,6 +18,20 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
// #4547: unspaced Chinese counted as one word per run.
|
||||
assert_eq!(count_words("我们今天去公园。"), 7);
|
||||
assert_eq!(count_words("WordCraft是一个文字处理器,很好用!"), 1 + 11);
|
||||
assert_eq!(count_words("ひらがなとカタカナ"), 9);
|
||||
assert_eq!(count_words("カタカナ"), 4);
|
||||
assert_eq!(count_words("ガギ"), 2, "voiced sound marks belong to their kana");
|
||||
assert_eq!(count_words("。、・「」"), 0);
|
||||
// Korean is spaced: Hangul counts by tokens.
|
||||
assert_eq!(count_words("안녕하세요 세계"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
let mut d = Document::new();
|
||||
@@ -61,6 +77,23 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
assert_eq!(d.word_count(), 5);
|
||||
}
|
||||
|
||||
/// #4547: a generated CJK list label such as 第十二章 still counts as one word.
|
||||
#[test]
|
||||
fn word_count_keeps_a_cjk_list_label_one_word() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].format = section::NumFormat::ChineseCounting;
|
||||
a.levels[0].text = "第%1章".into();
|
||||
a.levels[0].start = 12;
|
||||
let mut p = Paragraph::with_text("总结", CharProps::default());
|
||||
p.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
d.body = vec![Arc::new(Block::Para(p))];
|
||||
// The label 第十二章 stays one word (#4547 changed only document text); 总结 is two.
|
||||
assert_eq!(d.word_count(), 1 + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_and_section_mut() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
@@ -651,3 +684,66 @@ fn control_markers_kept_are_sorted() {
|
||||
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_linear_time() {
|
||||
let mut d = Document::new();
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
const COUNT: usize = 2_000;
|
||||
t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect();
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
let timer = std::time::Instant::now();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables.len(), COUNT);
|
||||
for (i, (path, nth, is_open)) in tables.iter().enumerate() {
|
||||
assert_eq!(*path, Path::top(0));
|
||||
assert_eq!(*nth, i);
|
||||
assert!(*is_open);
|
||||
}
|
||||
|
||||
assert!(d.has_unbalanced_controls());
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, COUNT);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed());
|
||||
let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") };
|
||||
assert!(tbl.controls.open.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_table_open_markers_mixed_tables_and_paired() {
|
||||
let mut d = Document::new();
|
||||
let mut t0 = Table::new(1, 1, 100.0);
|
||||
t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect();
|
||||
t0.controls.close = 1;
|
||||
|
||||
let mut t1 = Table::new(1, 1, 100.0);
|
||||
t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect();
|
||||
|
||||
d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))];
|
||||
|
||||
let mut paras = Vec::new();
|
||||
let mut tables = Vec::new();
|
||||
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
|
||||
|
||||
assert_eq!(paras.len(), 0);
|
||||
assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]);
|
||||
|
||||
let removed = d.balance_controls();
|
||||
assert_eq!(removed, 4);
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
|
||||
let Block::Table(res0) = &*d.body[0] else { panic!("expected table") };
|
||||
assert_eq!(res0.controls.open.len(), 1);
|
||||
assert_eq!(res0.controls.open[0].tag, "t0_2");
|
||||
assert_eq!(res0.controls.close, 1);
|
||||
|
||||
let Block::Table(res1) = &*d.body[1] else { panic!("expected table") };
|
||||
assert!(res1.controls.open.is_empty());
|
||||
assert_eq!(res1.controls.close, 0);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::*;
|
||||
use crate::para::{InlineObject, NoteKind, OBJ};
|
||||
|
||||
fn reference(id: u32, kind: NoteKind) -> InlineObject {
|
||||
InlineObject::NoteRef { kind, id, custom: "*".into() }
|
||||
}
|
||||
|
||||
fn styled(len: usize, style: &str) -> Run {
|
||||
Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() {
|
||||
for (foot, end, expected) in [
|
||||
(true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]),
|
||||
(false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
(true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
|
||||
] {
|
||||
let mut d = Document::new();
|
||||
let note = |style: &str| {
|
||||
let mut p = Paragraph::with_text("Original note 漢", CharProps::default());
|
||||
p.props.style = Some(style.into());
|
||||
p.mark.style = Some(style.into());
|
||||
vec![para_block(p)]
|
||||
};
|
||||
let f = d.add_part(PartKind::Footnote, note("FootnoteText"));
|
||||
let e = d.add_part(PartKind::Endnote, note("EndnoteText"));
|
||||
let p = Paragraph {
|
||||
text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"),
|
||||
objects: vec![
|
||||
reference(f, NoteKind::Footnote),
|
||||
InlineObject::BookmarkStart { name: "kept".into() },
|
||||
reference(e, NoteKind::Endnote),
|
||||
reference(999, NoteKind::Footnote),
|
||||
reference(f, NoteKind::Footnote),
|
||||
],
|
||||
runs: vec![
|
||||
styled(2, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "EndnoteReference"),
|
||||
styled(3, "FootnoteReference"),
|
||||
],
|
||||
..Paragraph::new()
|
||||
};
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks = vec![para_block(p.clone())];
|
||||
d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))];
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]);
|
||||
let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]);
|
||||
let before = d.clone();
|
||||
assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end));
|
||||
for (story, path) in [
|
||||
(StoryRef::Body, Path::top(0)),
|
||||
(StoryRef::Body, Path(vec![1, 0, 0, 0])),
|
||||
(StoryRef::Part(h), Path::top(0)),
|
||||
(StoryRef::Part(t), Path::top(0)),
|
||||
] {
|
||||
let got = d.para(story, &path).unwrap();
|
||||
assert_eq!(got.text, p.text);
|
||||
assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(), expected);
|
||||
assert!(got.runs.iter().all(|r| r.props.italic == Some(true)));
|
||||
assert_eq!(got.objects[1], p.objects[1]);
|
||||
assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged");
|
||||
assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote }));
|
||||
assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote }));
|
||||
assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion");
|
||||
}
|
||||
for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] {
|
||||
let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap();
|
||||
assert_eq!(got.text, before.plain_text(StoryRef::Part(id)));
|
||||
assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from }));
|
||||
assert_eq!(got.mark.style, got.props.style);
|
||||
}
|
||||
let mut missing = Paragraph {
|
||||
text: format!("{OBJ}{OBJ}"),
|
||||
objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })],
|
||||
runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")],
|
||||
..Paragraph::new()
|
||||
};
|
||||
missing.runs.push(styled(usize::MAX, "FootnoteReference"));
|
||||
d.body = vec![para_block(missing)];
|
||||
d.convert_notes(true, true);
|
||||
let got = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
assert_eq!(
|
||||
got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(),
|
||||
["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dense(n: usize) -> Document {
|
||||
let mut d = Document::new();
|
||||
let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]);
|
||||
d.body = vec![para_block(Paragraph {
|
||||
text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)),
|
||||
objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(),
|
||||
runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(),
|
||||
..Paragraph::new()
|
||||
})];
|
||||
d
|
||||
}
|
||||
|
||||
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
|
||||
// evidence; the semantic assertions still run on those platforms.
|
||||
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
let (small, big) = (dense(2_000), dense(16_000));
|
||||
let convert = |input: &Document| {
|
||||
let mut d = input.clone();
|
||||
assert_eq!(d.convert_notes(true, false), 1);
|
||||
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let n = p.objects.len();
|
||||
assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference")));
|
||||
assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference")));
|
||||
std::hint::black_box(d);
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
|
||||
let b = cpu_cost(|| convert(&big));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,36 @@ use super::parts::PartKind;
|
||||
use super::position::Path;
|
||||
use super::{InlineObject, Numbering, numbering, para, section, styles};
|
||||
|
||||
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
|
||||
/// Words in `s`, as Word counts them: tokens separated by whitespace that contain at least one
|
||||
/// alphanumeric char, plus each Han ideograph and kana as a word of its own (Chinese and Japanese
|
||||
/// are written without spaces, #4547; Korean is spaced, so Hangul counts by tokens).
|
||||
pub fn count_words(s: &str) -> usize {
|
||||
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
// The half-width voiced sound marks belong to the kana before them, not a word of their own.
|
||||
let word_char = |c: char| c.is_alphanumeric() && !matches!(c, '\u{FF9E}' | '\u{FF9F}');
|
||||
let tokens = s.split(|c: char| c.is_whitespace() || c == para::OBJ || is_cjk_word(c)).filter(|w| w.chars().any(word_char)).count();
|
||||
tokens.saturating_add(s.chars().filter(|c| is_cjk_word(*c)).count())
|
||||
}
|
||||
|
||||
/// Words in a generated list label: whitespace-separated tokens holding a letter or digit, so a
|
||||
/// CJK label such as 第十二章 stays one word (how Word counts those is unverified).
|
||||
fn count_label_words(s: &str) -> usize {
|
||||
s.split(char::is_whitespace).filter(|w| w.chars().any(char::is_alphanumeric)).count()
|
||||
}
|
||||
|
||||
/// A Han ideograph or kana letter (not CJK punctuation), which Word counts as one word.
|
||||
fn is_cjk_word(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{3005}'..='\u{3007}' // 々 〆 〇
|
||||
| '\u{3031}'..='\u{3035}' | '\u{303B}' // vertical kana repeat marks, 〻
|
||||
| '\u{3040}'..='\u{30FF}' // hiragana, katakana
|
||||
| '\u{31F0}'..='\u{31FF}' // katakana phonetic extensions
|
||||
| '\u{3400}'..='\u{4DBF}' // extension A
|
||||
| '\u{4E00}'..='\u{9FFF}' // unified ideographs
|
||||
| '\u{F900}'..='\u{FAFF}' // compatibility ideographs
|
||||
| '\u{FF66}'..='\u{FF9D}' // half-width katakana
|
||||
| '\u{1B000}'..='\u{1B16F}' // kana supplement and extended
|
||||
| '\u{20000}'..='\u{3FFFF}' // extensions B and later
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
@@ -146,7 +173,7 @@ pub(crate) fn story_word_count_of(
|
||||
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
|
||||
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
|
||||
{
|
||||
total = total.saturating_add(count_words(&label));
|
||||
total = total.saturating_add(count_label_words(&label));
|
||||
}
|
||||
total = total.saturating_add(count_words(&p.plain_text()));
|
||||
}
|
||||
|
||||
@@ -90,13 +90,16 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
let tag = child_text(src_el, "Tag");
|
||||
let raw_kind = child_text(src_el, "SourceType");
|
||||
let kind = crate::write::from_source_type(&raw_kind);
|
||||
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
let (author, corporate, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
Some(a) => {
|
||||
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
let role = a.els().find(|e| e.local() == "Author");
|
||||
let auth = role.map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
// Word's Corporate Author: one name, kept whole (`b:Corporate`, #4848).
|
||||
let corporate = role.map(|r| child_text(r, "Corporate").trim().to_string()).unwrap_or_default();
|
||||
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
(auth, corporate, ed)
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
None => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
|
||||
let title = child_text(src_el, "Title");
|
||||
@@ -132,11 +135,12 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
};
|
||||
|
||||
// Keep sources with some content, once each (by tag, or title and author untagged).
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && corporate.is_empty() && editor.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
let who = [&author, &corporate, &editor].into_iter().find(|w| !w.is_empty()).cloned().unwrap_or_default();
|
||||
seen.untagged.insert((title.clone(), who))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
@@ -147,6 +151,7 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
corporate,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
@@ -265,6 +270,25 @@ mod tests {
|
||||
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
|
||||
}
|
||||
|
||||
/// #4848: a corporate author (Word's Corporate Author box, `b:Corporate`) is read whole, and
|
||||
/// an untagged source with only a corporate author is still kept, once.
|
||||
#[test]
|
||||
fn a_corporate_author_is_read_whole() {
|
||||
let xml = br#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography">
|
||||
<b:Source><b:Tag>Wor20</b:Tag><b:SourceType>Report</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate> World Health Organization </b:Corporate></b:Author></b:Author>
|
||||
<b:Title>World report on hearing</b:Title><b:Year>2020</b:Year></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
<b:Source><b:SourceType>Book</b:SourceType>
|
||||
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
|
||||
</b:Sources>"#;
|
||||
let f = read_sources(xml).unwrap();
|
||||
assert_eq!(f.sources.len(), 2, "{:?}", f.sources);
|
||||
assert_eq!((f.sources[0].corporate.as_str(), f.sources[0].author.as_str()), ("World Health Organization", ""));
|
||||
assert_eq!(f.sources[1].corporate, "Harbor Guild, Inc.", "a comma doesn't split a corporate name");
|
||||
}
|
||||
|
||||
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
|
||||
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
|
||||
#[test]
|
||||
|
||||
@@ -193,6 +193,25 @@ fn hostile_encryption_info_errors_without_panicking() {
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
/// The key a real-world Standard-encrypted package derives: Apache POI's test file
|
||||
/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel
|
||||
/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code —
|
||||
/// the file decrypts to a valid package under it and nothing else opens that file — so real
|
||||
/// Word 2007 documents stay pinned, not just spec-derived ones.
|
||||
#[test]
|
||||
fn standard_key_matches_a_real_world_package() {
|
||||
let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e];
|
||||
const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c";
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY);
|
||||
// A different password or salt gives a different key; the key is exactly the length
|
||||
// asked for, for each AES width.
|
||||
assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY);
|
||||
assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY);
|
||||
assert_eq!(standard_key(&salt, "tika", 24).len(), 24);
|
||||
assert_eq!(standard_key(&salt, "tika", 32).len(), 32);
|
||||
}
|
||||
|
||||
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
|
||||
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
|
||||
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
|
||||
|
||||
@@ -25,11 +25,11 @@ pub fn read(root: &El) -> Vec<CustomProp> {
|
||||
let Some(v) = p.els().next() else { continue };
|
||||
let kind = v.local();
|
||||
let prop = if SIMPLE.contains(&kind) {
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() }
|
||||
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
} else {
|
||||
let mut xml = String::new();
|
||||
el_xml(v, &mut xml, 0);
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml }
|
||||
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
|
||||
};
|
||||
out.push(prop);
|
||||
}
|
||||
@@ -51,7 +51,12 @@ pub fn write(props: &[CustomProp]) -> Vec<u8> {
|
||||
if p.kind == "raw" && !raw_is_wellformed(&p.value) {
|
||||
continue;
|
||||
}
|
||||
w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]);
|
||||
let pid = pid.to_string();
|
||||
let mut attrs = vec![("fmtid", FMTID), ("pid", &pid), ("name", &p.name)];
|
||||
if let Some(target) = &p.link_target {
|
||||
attrs.push(("linkTarget", target));
|
||||
}
|
||||
w.open("property", &attrs);
|
||||
if p.kind == "raw" {
|
||||
w.raw(&p.value);
|
||||
} else {
|
||||
|
||||
@@ -210,18 +210,20 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
if let Some(p) = part(rt::SETTINGS) {
|
||||
lenient("settings", r.read_settings(&p));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
// Comments before notes: a comment range inside a footnote or endnote resolves its id against
|
||||
// the comments already read, and was dropped when the notes came first.
|
||||
if let Some(p) = part(rt::COMMENTS) {
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
let ids = part(rt::COMMENTS_IDS);
|
||||
let cex = part(rt::COMMENTS_EXTENSIBLE);
|
||||
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
|
||||
}
|
||||
if let Some(p) = part(rt::FOOTNOTES) {
|
||||
lenient("footnotes", r.read_notes(&p, true));
|
||||
}
|
||||
if let Some(p) = part(rt::ENDNOTES) {
|
||||
lenient("endnotes", r.read_notes(&p, false));
|
||||
}
|
||||
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
|
||||
// package root or the main part; Word's own files sometimes leave the relationship out, so
|
||||
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||
|
||||
@@ -23,8 +23,8 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string();
|
||||
let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string);
|
||||
wordcraft_doc::props::TextFrame {
|
||||
w: len("w:w").filter(|v| *v > 0.0),
|
||||
h: len("w:h").map(f32::abs).filter(|v| *v > 0.0),
|
||||
w: len("w:w").filter(|v| *v >= 0.0),
|
||||
h: len("w:h").map(f32::abs),
|
||||
h_exact: k.attr("w:hRule") == Some("exact"),
|
||||
x: len("w:x").unwrap_or(0.0),
|
||||
y: len("w:y").unwrap_or(0.0),
|
||||
@@ -258,6 +258,8 @@ impl PropCtx {
|
||||
"w:imprint" => c.engrave = Some(on_off(k)),
|
||||
"w:noProof" => c.no_proof = Some(on_off(k)),
|
||||
"w:vanish" => c.hidden = Some(on_off(k)),
|
||||
"w:webHidden" => c.web_hidden = Some(on_off(k)),
|
||||
"w:snapToGrid" => c.snap_to_grid = Some(on_off(k)),
|
||||
"w:rtl" => c.rtl = Some(on_off(k)),
|
||||
"w:color" => {
|
||||
c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) {
|
||||
|
||||
@@ -42,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String {
|
||||
n.to_string()
|
||||
}
|
||||
|
||||
/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`.
|
||||
fn builtin_heading_level(name: &str) -> Option<u8> {
|
||||
let name = name.trim();
|
||||
let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?;
|
||||
digit.parse::<u8>().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1)
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
|
||||
@@ -115,7 +122,7 @@ impl Reader<'_> {
|
||||
priority: s.child_val("w:uiPriority").and_then(u32_of),
|
||||
quick: flag(s, "w:qFormat").unwrap_or(false),
|
||||
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")),
|
||||
locked: flag(s, "w:locked").unwrap_or(false),
|
||||
// Only where the writer couldn't tell from the id (it flags the English ids).
|
||||
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
|
||||
@@ -131,6 +138,16 @@ impl Reader<'_> {
|
||||
if let Some(p) = s.child("w:pPr") {
|
||||
st.para = self.pc.ppr(p).0;
|
||||
}
|
||||
// Word gives its built-in heading styles their outline level whether or not the file
|
||||
// writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the
|
||||
// Navigation pane and the table of contents, whatever its id (#4696).
|
||||
if kind == StyleKind::Paragraph
|
||||
&& st.builtin
|
||||
&& st.para.outline_level.is_none()
|
||||
&& let Some(level) = s.child_val("w:name").and_then(builtin_heading_level)
|
||||
{
|
||||
st.para.outline_level = Some(level);
|
||||
}
|
||||
if let Some(rp) = s.child("w:rPr") {
|
||||
st.chr = self.pc.rpr(rp);
|
||||
}
|
||||
|
||||
@@ -121,10 +121,16 @@ pub(super) fn bibliography_xml(doc: &Document) -> Vec<u8> {
|
||||
w.open("b:Source", &[]);
|
||||
w.leaf("b:Tag", &[], &src.tag);
|
||||
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
|
||||
// Author & Editor
|
||||
if !src.author.is_empty() || !src.editor.is_empty() {
|
||||
// Author & Editor. A role holds a name list or a corporate name, not both (ECMA-376
|
||||
// `CT_NameOrCorporateType`), so a corporate author takes the author's place (#4848).
|
||||
let corporate = src.corporate.trim();
|
||||
if !src.author.is_empty() || !corporate.is_empty() || !src.editor.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
if !src.author.is_empty() {
|
||||
if !corporate.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
w.leaf("b:Corporate", &[], corporate);
|
||||
w.close("b:Author");
|
||||
} else if !src.author.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
write_name_list(&mut w, &src.author);
|
||||
w.close("b:Author");
|
||||
|
||||
@@ -429,13 +429,15 @@ fn write_comments(
|
||||
("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());
|
||||
// Assigned in BTreeMap comment-ID order above, including gaps for missing parts.
|
||||
let pid_of =
|
||||
|cid: u32| comment_paras.binary_search_by_key(&cid, |(c, _)| *c).ok().and_then(|i| comment_paras.get(i)).map(|(_, p)| p.as_str());
|
||||
for (cid, c) in &doc.comments {
|
||||
let Some(pid) = pid_of(*cid) else { continue };
|
||||
let done = if c.resolved { "1" } else { "0" };
|
||||
match c.parent.and_then(pid_of) {
|
||||
Some(parent) => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:paraIdParent", &parent), ("w15:done", done)]),
|
||||
None => w.empty("w15:commentEx", &[("w15:paraId", &pid), ("w15:done", done)]),
|
||||
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");
|
||||
|
||||
@@ -53,7 +53,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
toggle(w, "w:emboss", c.emboss);
|
||||
toggle(w, "w:imprint", c.engrave);
|
||||
toggle(w, "w:noProof", c.no_proof);
|
||||
toggle(w, "w:snapToGrid", c.snap_to_grid);
|
||||
toggle(w, "w:vanish", c.hidden);
|
||||
toggle(w, "w:webHidden", c.web_hidden);
|
||||
match c.color {
|
||||
Some(TextColor::Auto) => w.val("w:color", "auto"),
|
||||
Some(TextColor::Rgb(rgb)) => w.val("w:color", &rgb.hex()),
|
||||
|
||||
@@ -172,6 +172,8 @@ pub(super) fn write_comment_ids(
|
||||
if comment_paras.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Keep the existing exact-string reservation rule, including comments without a written part.
|
||||
let reserved: std::collections::HashSet<&str> = doc.comments.values().filter_map(|c| c.durable_id.as_deref()).collect();
|
||||
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut next = 0x1000_0000u32;
|
||||
let mut rows: Vec<(String, String, Option<String>)> = Vec::new();
|
||||
@@ -183,7 +185,7 @@ pub(super) fn write_comment_ids(
|
||||
None => loop {
|
||||
next = next.saturating_add(1);
|
||||
let d = format!("{next:08X}");
|
||||
if !used.contains(&d) && !doc.comments.values().any(|o| o.durable_id.as_deref() == Some(d.as_str())) {
|
||||
if !used.contains(&d) && !reserved.contains(d.as_str()) {
|
||||
break d;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -155,6 +155,38 @@ fn zotero_fields_and_prefs_round_trip() {
|
||||
assert_eq!(read(&write(&d2).unwrap()).unwrap().custom_props, d.custom_props);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_property_bookmark_links_survive_saves_and_value_edits() {
|
||||
let props = r#"<c:Properties xmlns:c="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:v="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Linked" linkTarget="OriginalBookmark"><v:lpwstr>Original value</v:lpwstr></c:property>
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="Raw" linkTarget="RawBookmark"><v:vector size="1" baseType="lpwstr"><v:lpwstr>Ω</v:lpwstr></v:vector></c:property>
|
||||
<c:property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="Unlinked"><v:i4>17</v:i4></c:property>
|
||||
</c:Properties>"#;
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:bookmarkStart w:id="1" w:name="OriginalBookmark"/>{}<w:bookmarkEnd w:id="1"/></w:p><w:p><w:bookmarkStart w:id="2" w:name="RawBookmark"/>{}<w:bookmarkEnd w:id="2"/></w:p></w:body></w:document>"#,
|
||||
t("Original value"),
|
||||
t("Ω")
|
||||
);
|
||||
let bytes = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("docProps/custom.xml", props.as_bytes())]);
|
||||
let mut d = read(&bytes).unwrap();
|
||||
for value in ["Original value", "Edited value"] {
|
||||
d.set_custom_prop("linked", value);
|
||||
let before = d.custom_props.clone();
|
||||
for _ in 0..2 {
|
||||
let out = write(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("docProps/custom.xml").unwrap(), &mut xml).unwrap();
|
||||
assert!(xml.contains(r#"linkTarget="OriginalBookmark""#), "{xml}");
|
||||
assert!(xml.contains(r#"linkTarget="RawBookmark""#), "{xml}");
|
||||
assert_eq!(xml.matches("linkTarget=").count(), 2, "unlinked properties stay unlinked");
|
||||
d = read(&out).unwrap();
|
||||
assert_eq!(d.custom_props, before);
|
||||
assert_eq!(d.custom_prop("Linked"), Some(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_and_resultless_addin_fields() {
|
||||
// A field left open at the end of the document, and one with no result at all.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Saving large comment threads must not search all comments for each emitted ID.
|
||||
|
||||
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
|
||||
|
||||
fn comments(n: u32, imported: bool) -> Document {
|
||||
let mut d = Document::from_text("Original comment save fixture.");
|
||||
for i in 0..n {
|
||||
// Sparse IDs and replies to a later comment also exercise paragraph-ID lookup order.
|
||||
let id = i * 3 + 7;
|
||||
d.parts.insert(id, Part { kind: PartKind::Comment, blocks: vec![para_block(Paragraph::new())], ..Default::default() });
|
||||
d.comments.insert(
|
||||
id,
|
||||
Comment { part: id, parent: (i + 1 < n).then_some(id + 3), durable_id: imported.then(|| format!("{:08X}", i + 1)), ..Default::default() },
|
||||
);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn cpu_cost(mut f: impl FnMut()) -> f64 {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let (wall, before) = (std::time::Instant::now(), ran());
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => (b - a) as f64 * 1e-9,
|
||||
_ => wall.elapsed().as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_comments_save_without_quadratic_id_searches() {
|
||||
for imported in [true, false] {
|
||||
let small = comments(4_000, imported);
|
||||
let big = comments(32_000, imported);
|
||||
let save = |d: &Document| drop(std::hint::black_box(wordcraft_docx::write(d).unwrap()));
|
||||
save(&small);
|
||||
save(&big);
|
||||
let mut ratios = Vec::new();
|
||||
// Alternating inputs and thread CPU time leave scheduler delays out on Linux. Elsewhere
|
||||
// use elapsed time; retry ratios far from linear before declaring a regression.
|
||||
for _ in 0..3 {
|
||||
let (mut s, mut b) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
s = s.min(cpu_cost(|| (0..8).for_each(|_| save(&small))) / 8.0);
|
||||
b = b.min(cpu_cost(|| save(&big)));
|
||||
}
|
||||
let ratio = b / s.max(1e-9);
|
||||
eprintln!("comments imported={imported}: {s:.4}s -> {b:.4}s, ratio {ratio:.2}");
|
||||
if ratio < 24.0 {
|
||||
break;
|
||||
}
|
||||
ratios.push(ratio);
|
||||
}
|
||||
assert!(ratios.len() < 3, "8x comments took {ratios:?}x CPU: repeated ID searches");
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,68 @@ fn paragraph_default_presence_survives_repeated_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_frame_keeps_normal_flow() {
|
||||
const ROUND_TRIPS: usize = 2;
|
||||
let zero = r#"<w:framePr w:w="0" w:h="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/>"#;
|
||||
let sized = r#"<w:framePr w:w="2400" w:h="1200" w:x="1440" w:y="2880" w:hAnchor="page" w:vAnchor="page" w:hRule="exact"/>"#;
|
||||
for frame in [zero, sized] {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr>{frame}<w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>
|
||||
<w:style w:type="paragraph" w:styleId="Framed"><w:name w:val="Framed"/><w:pPr>{sized}</w:pPr></w:style></w:styles>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Header alpha</w:t></w:r></w:p><w:p><w:r><w:t>Header beta</w:t></w:r></w:p></w:hdr>"#);
|
||||
let footer = format!(r#"<w:ftr {W_NS}><w:p><w:r><w:t>Footer delta</w:t></w:r></w:p></w:ftr>"#);
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Body one</w:t></w:r></w:p>
|
||||
<w:p><w:pPr>{sized}</w:pPr><w:r><w:t>Direct frame</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Styled frame</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Framed"/>{zero}</w:pPr><w:r><w:t>Frame reset</w:t></w:r></w:p>
|
||||
<w:sectPr><w:headerReference w:type="default" r:id="header"/><w:footerReference w:type="default" r:id="footer"/></w:sectPr>"#
|
||||
);
|
||||
let mut document = wordcraft_docx::read(&docx(
|
||||
&body,
|
||||
&[("styles", "styles", "styles.xml"), ("header", "header", "header1.xml"), ("footer", "footer", "footer1.xml")],
|
||||
&[("word/styles.xml", &styles), ("word/header1.xml", &header), ("word/footer1.xml", &footer)],
|
||||
))
|
||||
.unwrap();
|
||||
for pass in 0..=ROUND_TRIPS {
|
||||
let body = paras(&document);
|
||||
let resolved = document.styles.resolve_para(&body[0].props);
|
||||
assert!(resolved.frame.is_none(), "document defaults never create a frame");
|
||||
assert!(document.styles.default_para.frame.is_none());
|
||||
let reset = body[3].props.frame.as_ref().unwrap();
|
||||
assert_eq!((reset.w, reset.h), (Some(0.0), Some(0.0)), "the direct reset survives saving");
|
||||
assert_eq!(resolved.space_after, 8.0);
|
||||
assert!(document.styles.resolve_para(&body[1].props).frame.is_some());
|
||||
assert!(document.styles.resolve_para(&body[2].props).frame.is_some());
|
||||
assert!(document.styles.resolve_para(&body[3].props).frame.is_none());
|
||||
for id in [document.last_section.headers.default, document.last_section.footers.default] {
|
||||
for block in &document.parts.get(&id.unwrap()).unwrap().blocks {
|
||||
let paragraph = block.as_para().unwrap();
|
||||
assert!(document.styles.resolve_para(¶graph.props).frame.is_none(), "defaults do not frame headers or footers");
|
||||
}
|
||||
}
|
||||
if pass == ROUND_TRIPS {
|
||||
break;
|
||||
}
|
||||
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_zero_side_is_automatic() {
|
||||
for (attrs, width, height) in
|
||||
[(r#"w:w="0" w:h="1200""#, None, Some(60.0)), (r#"w:w="2400" w:h="0" w:hRule="exact""#, Some(120.0), None), ("", None, None)]
|
||||
{
|
||||
let document = read_body(&format!(r#"<w:p><w:pPr><w:framePr {attrs}/></w:pPr><w:r><w:t>Original automatic frame</w:t></w:r></w:p>"#));
|
||||
let paragraph = paras(&document)[0];
|
||||
let frame = document.styles.resolve_para(¶graph.props).frame.unwrap();
|
||||
assert_eq!((frame.w, frame.h), (width, height));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_paragraph_defaults_can_be_edited() {
|
||||
let styles =
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! #4696: Word's built-in heading styles have their outline level even when the file's
|
||||
//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows,
|
||||
//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever
|
||||
//! their style ids) and body text for a custom style.
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::Paragraph;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
|
||||
fn package(styles: &str, body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#);
|
||||
let styles = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}">{styles}</w:styles>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in
|
||||
[("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())]
|
||||
{
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn para(style: &str, text: &str) -> String {
|
||||
format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#)
|
||||
}
|
||||
|
||||
fn levels(d: &wordcraft_doc::Document) -> Vec<Option<u8>> {
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() {
|
||||
let styles = r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Titolo2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading9"><w:name w:val="Heading 9"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Below"><w:name w:val="Below heading 2"/><w:basedOn w:val="Titolo2"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="1" w:styleId="MyHead"><w:name w:val="My heading 3"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading10"><w:name w:val="heading 10"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="on" w:styleId="Fake4"><w:name w:val="heading 4"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:pPr><w:outlineLvl w:val="5"/></w:pPr></w:style>"#;
|
||||
let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"]
|
||||
.iter()
|
||||
.map(|s| para(s, "x"))
|
||||
.collect::<String>();
|
||||
let d = wordcraft_docx::read(&package(styles, &body)).unwrap();
|
||||
// Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style,
|
||||
// "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a
|
||||
// level the file gives is kept.
|
||||
let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None];
|
||||
assert_eq!(levels(&d), want);
|
||||
// The level survives a save and reopen.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(levels(&again), want);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod bidi_runs;
|
||||
mod cell_margin_inheritance;
|
||||
mod char_units;
|
||||
mod citations;
|
||||
mod comment_save_cost;
|
||||
mod complex_script_inheritance;
|
||||
mod content_control_save_cost;
|
||||
mod content_controls;
|
||||
@@ -13,14 +14,17 @@ mod east_asian_lang;
|
||||
mod east_asian_layout;
|
||||
mod fixtures;
|
||||
mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
mod row_property_exceptions;
|
||||
mod run_props;
|
||||
mod section_form_protection;
|
||||
mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
@@ -28,6 +32,7 @@ mod section_valign;
|
||||
mod settings_merge;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
mod undefined_style;
|
||||
mod vml_shapes;
|
||||
mod word_xml;
|
||||
mod wordml2003_write;
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Comments anchored inside footnotes and endnotes keep their ranges on read. All markup here is
|
||||
//! synthetic, written from ECMA-376 §17.13.4 and §17.11.
|
||||
|
||||
use wordcraft_doc::para::{NoteKind, OBJ};
|
||||
use wordcraft_doc::{Document, InlineObject};
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
const DOC_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/></Relationships>"#;
|
||||
|
||||
fn note(tag: &str, mark: &str, comment: u32, text: &str) -> String {
|
||||
format!(
|
||||
r#"<w:{tag} w:id="1"><w:p><w:r><w:{mark}/></w:r><w:commentRangeStart w:id="{comment}"/><w:r><w:t xml:space="preserve"> {text}</w:t></w:r><w:commentRangeEnd w:id="{comment}"/><w:r><w:commentReference w:id="{comment}"/></w:r></w:p></w:{tag}>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn read_doc() -> Document {
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let footnotes = format!(r#"<w:footnotes {W_NS}>{}</w:footnotes>"#, note("footnote", "footnoteRef", 7, "Foot."));
|
||||
let endnotes = format!(r#"<w:endnotes {W_NS}>{}</w:endnotes>"#, note("endnote", "endnoteRef", 8, "End."));
|
||||
let comments = format!(
|
||||
r#"<w:comments {W_NS}><w:comment w:id="7" w:author="Ann" w:initials="A"><w:p><w:r><w:t>On the footnote</w:t></w:r></w:p></w:comment><w:comment w:id="8" w:author="Bob" w:initials="B"><w:p><w:r><w:t>On the endnote</w:t></w:r></w:p></w:comment></w:comments>"#
|
||||
);
|
||||
let bytes = zip(&[
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", DOC_RELS.as_bytes()),
|
||||
("word/footnotes.xml", footnotes.as_bytes()),
|
||||
("word/endnotes.xml", endnotes.as_bytes()),
|
||||
("word/comments.xml", comments.as_bytes()),
|
||||
]);
|
||||
wordcraft_docx::read(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// comments.xml has to be read before the notes: a note's `w:commentRangeStart` resolves its id
|
||||
/// against the comments already read, and was dropped when the notes came first.
|
||||
#[test]
|
||||
fn comment_ranges_inside_notes_are_kept() {
|
||||
let d = read_doc();
|
||||
let body = d.body[0].as_para().unwrap();
|
||||
let refs: Vec<(NoteKind, u32)> = body
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::NoteRef { kind, id, .. } => Some((*kind, *id)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(refs.len(), 2, "{:?}", body.objects);
|
||||
for ((kind, id), (author, text)) in refs.into_iter().zip([("Ann", " Foot."), ("Bob", " End.")]) {
|
||||
let p = d.parts.get(&id).unwrap().blocks[0].as_para().unwrap();
|
||||
let comment = match &p.objects[..] {
|
||||
[InlineObject::NoteRef { kind: k, id: n, .. }, InlineObject::CommentStart { id: c }, InlineObject::CommentEnd { id: e }]
|
||||
if *k == kind && *n == id && c == e =>
|
||||
{
|
||||
*c
|
||||
}
|
||||
o => panic!("{kind:?} {id}: {o:?}"),
|
||||
};
|
||||
assert_eq!(d.comments.get(&comment).unwrap().author, author);
|
||||
assert_eq!(p.text.chars().filter(|c| *c == OBJ).count(), 3);
|
||||
assert_eq!(p.plain_text(), text);
|
||||
}
|
||||
}
|
||||
@@ -443,3 +443,51 @@ fn embedded_fonts_load_and_save_when_the_package_spells_the_font_table_in_capita
|
||||
assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys());
|
||||
assert_eq!(fonts[0].data, font_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_ids_keep_sparse_threads_reservations_and_missing_parts() {
|
||||
use wordcraft_doc::{Comment, Document, Paragraph, Part, PartKind, para_block};
|
||||
let mut doc = Document::from_text("Original thread fixture.");
|
||||
// New IDs must skip the exact durable ID reserved by a later comment, even one whose
|
||||
// missing part keeps it out of commentsIds.xml. Duplicate imported IDs are reassigned.
|
||||
let rows = [
|
||||
(3, None, Some(90), false, true),
|
||||
(11, Some("5a1b2c3d"), Some(90), true, true),
|
||||
(25, Some("5A1B2C3D"), Some(999), false, true),
|
||||
(90, Some("10000001"), None, true, true),
|
||||
(999, Some("10000002"), None, false, false),
|
||||
(u32::MAX, Some("not-hex"), Some(3), false, true),
|
||||
];
|
||||
for (id, durable, parent, resolved, has_part) in rows {
|
||||
let part = id;
|
||||
if has_part {
|
||||
doc.parts.insert(
|
||||
part,
|
||||
Part {
|
||||
kind: PartKind::Comment,
|
||||
blocks: vec![para_block(Paragraph::with_text(&format!("Comment {id} β"), Default::default()))],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
doc.comments.insert(id, Comment { part, parent, resolved, durable_id: durable.map(str::to_string), ..Default::default() });
|
||||
}
|
||||
let files = save(&doc);
|
||||
let (ids, _) = written_ids(&files);
|
||||
assert_eq!(ids.iter().map(|(_, id)| id.as_str()).collect::<Vec<_>>(), ["10000003", "5A1B2C3D", "10000004", "10000001", "10000005"]);
|
||||
let ex = text(&files, "word/commentsExtended.xml");
|
||||
assert_eq!(ex.matches("<w15:commentEx ").count(), 5);
|
||||
assert!(ex.contains(r#"w15:paraId="10000001" w15:paraIdParent="10000004" w15:done="0""#));
|
||||
assert!(ex.contains(r#"w15:paraId="10000002" w15:paraIdParent="10000004" w15:done="1""#));
|
||||
assert!(ex.contains(r#"w15:paraId="10000003" w15:done="0""#), "a missing parent part has no paragraph ID");
|
||||
assert!(ex.contains(r#"w15:paraId="10000006" w15:paraIdParent="10000001" w15:done="0""#));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
// Reader IDs need not match the sparse input IDs. Check the thread by authored text.
|
||||
let comment = |text: &str| back.comments.iter().find(|(_, c)| back.plain_text(wordcraft_doc::StoryRef::Part(c.part)).trim() == text).unwrap();
|
||||
let (root, _) = comment("Comment 90 β");
|
||||
assert_eq!(comment("Comment 3 β").1.parent, Some(*root));
|
||||
assert_eq!(comment("Comment 11 β").1.parent, Some(*root));
|
||||
assert!(comment("Comment 11 β").1.resolved);
|
||||
let (first, _) = comment("Comment 3 β");
|
||||
assert_eq!(comment(&format!("Comment {} β", u32::MAX)).1.parent, Some(*first));
|
||||
}
|
||||
@@ -89,6 +89,8 @@ fn every_char_prop_round_trips() {
|
||||
caps: Some(true),
|
||||
small_caps: Some(false),
|
||||
hidden: Some(true),
|
||||
web_hidden: Some(true),
|
||||
snap_to_grid: Some(false),
|
||||
spacing: Some(1.5),
|
||||
scale: Some(150.0),
|
||||
position: Some(3.0),
|
||||
@@ -2267,6 +2269,39 @@ fn bibliography_sources_round_trip() {
|
||||
assert_bibliography_sources_round_trip(&bytes);
|
||||
}
|
||||
|
||||
/// #4848: a corporate author saves as Word's `b:Corporate`, in place of a name list (a role holds
|
||||
/// one or the other), and reads back whole.
|
||||
#[test]
|
||||
fn a_corporate_author_round_trips() {
|
||||
let mut d = Document::new();
|
||||
d.sources.push(Source {
|
||||
tag: "Wor20".into(),
|
||||
kind: "report".into(),
|
||||
corporate: "World Health Organization".into(),
|
||||
title: "World report on hearing".into(),
|
||||
year: "2020".into(),
|
||||
editor: "Chadha, Shelly".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Both".into(),
|
||||
author: "Rivera, Alex".into(),
|
||||
corporate: "Harbor Guild, Inc.".into(),
|
||||
title: "Shared Spaces".into(),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let mut item_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
|
||||
assert!(item_xml.contains("<b:Author><b:Author><b:Corporate>World Health Organization</b:Corporate></b:Author><b:Editor>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Corporate>Harbor Guild, Inc.</b:Corporate>"), "{item_xml}");
|
||||
assert!(!item_xml.contains("<b:Last>Rivera</b:Last>"), "the corporate author takes the person authors' place: {item_xml}");
|
||||
let back = wordcraft_docx::read(&bytes).expect("read");
|
||||
let got: Vec<(&str, &str, &str)> = back.sources.iter().map(|s| (s.corporate.as_str(), s.author.as_str(), s.editor.as_str())).collect();
|
||||
assert_eq!(got, [("World Health Organization", "", "Chadha, Shelly"), ("Harbor Guild, Inc.", "", "")]);
|
||||
}
|
||||
|
||||
fn assert_bibliography_parts(bytes: &[u8]) {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
assert!(zip.by_name("customXml/item1.xml").is_ok());
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Run and style properties that must survive repeated DOCX saves.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
fn docx(body: &str, styles: &str) -> Vec<u8> {
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let document = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!(r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{styles}</w:styles>"#);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, xml) in [
|
||||
("_rels/.rels", root.to_string()),
|
||||
("word/document.xml", document),
|
||||
("word/_rels/document.xml.rels", rels.to_string()),
|
||||
("word/styles.xml", styles),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(xml.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
|
||||
let styles = r#"
|
||||
<w:style w:type="character" w:styleId="On"><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr></w:style>
|
||||
<w:style w:type="character" w:styleId="Off"><w:rPr><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr></w:style>
|
||||
"#;
|
||||
let body = r#"
|
||||
<w:p>
|
||||
<w:r><w:rPr><w:rStyle w:val="On"/></w:rPr><w:t>inherit</w:t></w:r>
|
||||
<w:r><w:rPr><w:snapToGrid/><w:webHidden/></w:rPr><w:t>direct true</w:t></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="On"/><w:snapToGrid w:val="0"/><w:webHidden w:val="0"/></w:rPr><w:t>override</w:t></w:r>
|
||||
<w:r><w:rPr><w:rStyle w:val="Off"/><w:vanish/></w:rPr><w:t>visible in print</w:t></w:r>
|
||||
<w:r><w:t>absent</w:t></w:r>
|
||||
</w:p>
|
||||
"#;
|
||||
let mut doc = wordcraft_docx::read(&docx(body, styles)).unwrap();
|
||||
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
|
||||
assert_eq!(p.runs[0].props.snap_to_grid, None);
|
||||
assert_eq!(p.runs[0].props.web_hidden, None);
|
||||
assert_eq!(p.runs[1].props.snap_to_grid, Some(true));
|
||||
assert_eq!(p.runs[1].props.web_hidden, Some(true));
|
||||
assert_eq!(p.runs[2].props.snap_to_grid, Some(false));
|
||||
assert_eq!(p.runs[2].props.web_hidden, Some(false));
|
||||
assert_eq!(p.runs[3].props.hidden, Some(true));
|
||||
assert_eq!(p.runs[3].props.web_hidden, None);
|
||||
assert_eq!(p.runs[4].props.snap_to_grid, None);
|
||||
assert_eq!(p.runs[4].props.web_hidden, None);
|
||||
assert!(!doc.styles.resolve_char(None, &p.runs[0].props).hidden, "w:webHidden does not hide Print Layout text");
|
||||
assert!(doc.styles.resolve_char(None, &p.runs[3].props).hidden, "w:vanish still controls hidden text");
|
||||
for pass in 0..2 {
|
||||
let out = wordcraft_docx::write(&doc).unwrap();
|
||||
let style_xml = part(&out, "word/styles.xml");
|
||||
assert!(style_xml.contains(r#"<w:snapToGrid/><w:webHidden/>"#), "pass {pass}: {style_xml}");
|
||||
assert!(style_xml.contains(r#"<w:snapToGrid w:val="0"/><w:webHidden w:val="0"/>"#), "pass {pass}: {style_xml}");
|
||||
let document_xml = part(&out, "word/document.xml");
|
||||
assert!(document_xml.contains(r#"<w:snapToGrid w:val="0"/>"#), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains(r#"<w:webHidden w:val="0"/>"#), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("<w:snapToGrid/><w:webHidden/>"), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("<w:vanish/>"), "pass {pass}: {document_xml}");
|
||||
assert!(document_xml.contains("visible in print"));
|
||||
doc = wordcraft_docx::read(&out).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! A paragraph whose `w:pStyle` names a style `styles.xml` doesn't define is formatted as Normal,
|
||||
//! as in Word, not with the document defaults alone (#5077). Pandoc writes footers as
|
||||
//! `<w:pStyle w:val="Footer"/>` without defining Footer.
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
use wordcraft_doc::{Block, Document, PartKind};
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn package(normal_id: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}" xmlns:r="{R}"><w:body><w:p><w:pPr><w:pStyle w:val="Footer"/></w:pPr><w:r><w:t>body</w:t></w:r></w:p><w:p><w:r><w:t>normal</w:t></w:r></w:p><w:sectPr><w:footerReference w:type="default" r:id="rId2"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let footer = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="{W}"><w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="center"/></w:pPr><w:r><w:t>1</w:t></w:r></w:p></w:ftr>"#
|
||||
);
|
||||
let styles = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}"><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="24"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="200"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="{normal_id}"><w:name w:val="Normal"/><w:pPr><w:spacing w:before="20" w:after="40" w:line="259" w:lineRule="auto"/></w:pPr><w:rPr><w:sz w:val="26"/></w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in [
|
||||
("_rels/.rels", root),
|
||||
("word/document.xml", doc.as_str()),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
("word/footer1.xml", footer.as_str()),
|
||||
] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn footer_para(d: &Document) -> &wordcraft_doc::Paragraph {
|
||||
let part = d.parts.values().find(|p| p.kind == PartKind::Footer).expect("a footer");
|
||||
match part.blocks.first().map(|b| &**b) {
|
||||
Some(Block::Para(p)) => p,
|
||||
_ => panic!("the footer starts with a paragraph"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_naming_an_undefined_style_is_formatted_as_normal() {
|
||||
// Also when the file's default paragraph style has another id (read as Normal).
|
||||
for normal_id in ["Normal", "a"] {
|
||||
let d = wordcraft_docx::read(&package(normal_id)).unwrap();
|
||||
let Some(Block::Para(body)) = d.body.first().map(|b| &**b) else { panic!("a body paragraph") };
|
||||
let Some(Block::Para(normal)) = d.body.get(1).map(|b| &**b) else { panic!("a second body paragraph") };
|
||||
let normal_rp = d.styles.resolve_para(&normal.props);
|
||||
assert_eq!((normal_rp.space_before, normal_rp.space_after), (1.0, 2.0), "{normal_id}");
|
||||
for p in [body, footer_para(&d)] {
|
||||
// The paragraph keeps the style it names, for the round trip.
|
||||
assert_eq!(p.props.style.as_deref(), Some("Footer"));
|
||||
let rp = d.styles.resolve_para(&p.props);
|
||||
assert_eq!((rp.space_before, rp.space_after), (1.0, 2.0), "{normal_id}: Normal's spacing, not the defaults' 10 pt after");
|
||||
assert_eq!(rp.line_spacing, LineSpacing::Multiple(259.0 / 240.0), "{normal_id}");
|
||||
assert_eq!(d.styles.resolve_char(Some("Footer"), &Default::default()).size, 13.0, "{normal_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undefined_style_is_written_back_as_it_was_named() {
|
||||
let d = wordcraft_docx::read(&package("Normal")).unwrap();
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(footer_para(&again).props.style.as_deref(), Some("Footer"));
|
||||
assert_eq!(again.styles.resolve_para(&footer_para(&again).props).space_after, 2.0);
|
||||
}
|
||||
@@ -273,8 +273,9 @@ fn set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else if let (Some(pg), Some(x), Some(y)) = (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
|
||||
let story = super::story_param(s, v);
|
||||
let l = s.layout();
|
||||
s.page_hint = pg as usize;
|
||||
l.hit(pg as usize, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
|
||||
let page = usize::try_from(pg).unwrap_or(usize::MAX);
|
||||
s.page_hint = page;
|
||||
l.hit(page, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
|
||||
} else {
|
||||
return Err(CmdError::Params("`pos` or `page`/`x`/`y` required".into()));
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::generated::page_of;
|
||||
use super::{CharProps, CmdError, CmdResult, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
|
||||
use super::{CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p};
|
||||
|
||||
pub(super) fn cross_ref(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let to = p::str(v, "to").unwrap_or("heading");
|
||||
@@ -77,13 +77,19 @@ pub(super) fn caption_label(p: &Paragraph, text: &str) -> Option<String> {
|
||||
(!label.is_empty() && label.chars().count() <= 40).then_some(label)
|
||||
}
|
||||
|
||||
/// Whether `p` is a caption: its style is Caption or one based on it, as a caption restyled with a
|
||||
/// style of its own (a `Table Caption` based on Caption) still is (#4411).
|
||||
pub(super) fn is_caption(doc: &Document, p: &Paragraph) -> bool {
|
||||
p.props.style.as_deref().is_some_and(|id| id == "Caption" || doc.styles.chain(id).iter().any(|s| s.id == "Caption"))
|
||||
}
|
||||
|
||||
/// The caption paragraphs of the body: (text, position, label).
|
||||
fn captions(s: &Session) -> Vec<(String, Pos, Option<String>)> {
|
||||
s.doc
|
||||
.para_paths(StoryRef::Body)
|
||||
.into_iter()
|
||||
.filter_map(|path| {
|
||||
let p = s.doc.para(StoryRef::Body, &path).filter(|p| p.props.style.as_deref() == Some("Caption"))?;
|
||||
let p = s.doc.para(StoryRef::Body, &path).filter(|p| is_caption(&s.doc, p))?;
|
||||
let text = p.plain_text();
|
||||
let label = caption_label(p, &text);
|
||||
Some((text, Pos { story: StoryRef::Body, path, off: 0 }, label))
|
||||
@@ -125,7 +131,7 @@ pub fn ref_targets(s: &Session, to: &str) -> Vec<(String, Pos)> {
|
||||
};
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = s.doc.para(StoryRef::Body, &path)
|
||||
&& p.props.style.as_deref() == Some("Caption")
|
||||
&& is_caption(&s.doc, p)
|
||||
&& p.plain_text().starts_with(label)
|
||||
{
|
||||
targets.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::cross_refs::caption_label;
|
||||
use super::cross_refs::{caption_label, is_caption};
|
||||
use super::render::entry;
|
||||
use super::sources::bib_order;
|
||||
use super::styles::style;
|
||||
use super::{
|
||||
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,
|
||||
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
|
||||
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
|
||||
};
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
@@ -86,8 +86,8 @@ fn figures_label(instr: &str) -> Option<String> {
|
||||
|
||||
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
|
||||
/// caption typed by hand, without a SEQ field, goes by its label.
|
||||
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
|
||||
if p.props.style.as_deref() != Some("Caption") {
|
||||
fn captioned_as(doc: &Document, p: &Paragraph, ident: &str) -> bool {
|
||||
if !is_caption(doc, p) {
|
||||
return false;
|
||||
}
|
||||
let seq = p.objects.iter().find_map(|o| match o {
|
||||
@@ -256,7 +256,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
if captioned_as(&s.doc, p, ident) {
|
||||
let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
|
||||
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
|
||||
}
|
||||
@@ -280,7 +280,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(ident) = &figures {
|
||||
if captioned_as(p, ident) {
|
||||
if captioned_as(&s.doc, p, ident) {
|
||||
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use super::Source;
|
||||
use super::styles::{Cite, Name, Names, style};
|
||||
|
||||
/// A source's corporate author, when it has one: written as it is in place of the person names
|
||||
/// (Word's Corporate Author, #4848).
|
||||
pub(super) fn corporate(src: &Source) -> Option<&str> {
|
||||
Some(src.corporate.trim()).filter(|c| !c.is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn authors(a: &str) -> Vec<(String, String)> {
|
||||
a.split(';')
|
||||
.map(str::trim)
|
||||
@@ -110,12 +116,13 @@ pub fn cite(src: &Source, style_name: &str, n: usize, pages: &str) -> String {
|
||||
let st = style(style_name);
|
||||
let au = authors(&src.author);
|
||||
let lasts: Vec<&str> = au.iter().map(|a| a.0.as_str()).collect();
|
||||
let who = match lasts.as_slice() {
|
||||
[] => src.title.clone(),
|
||||
[one] => one.to_string(),
|
||||
[first, ..] if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
|
||||
[a, b] => format!("{a} {} {b}", st.and),
|
||||
[init @ .., z] => format!("{} {} {z}", init.join(", "), st.and),
|
||||
let who = match (corporate(src), lasts.as_slice()) {
|
||||
(Some(c), _) => c.to_string(),
|
||||
(None, []) => src.title.clone(),
|
||||
(None, [one]) => one.to_string(),
|
||||
(None, [first, ..]) if lasts.len() >= st.et_al_from.max(2) => format!("{first} et al."),
|
||||
(None, [a, b]) => format!("{a} {} {b}", st.and),
|
||||
(None, [init @ .., z]) => format!("{} {} {z}", init.join(", "), st.and),
|
||||
};
|
||||
match st.cite {
|
||||
Cite::AuthorPage if pages.is_empty() => format!("({who})"),
|
||||
@@ -148,7 +155,7 @@ pub fn entry(src: &Source, style_name: &str, n: usize) -> String {
|
||||
let field = |key: &str| -> String {
|
||||
match key {
|
||||
"n" => n.to_string(),
|
||||
"names" => name_list(&au, &st.names),
|
||||
"names" => corporate(src).map_or_else(|| name_list(&au, &st.names), str::to_string),
|
||||
"year" if src.year.is_empty() => "n.d.".into(),
|
||||
"year" => src.year.clone(),
|
||||
"title" => src.title.clone(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::generated::update_generated;
|
||||
use super::render::{authors, cite};
|
||||
use super::render::{authors, cite, corporate};
|
||||
use super::styles::{CiteStyle, Order, style};
|
||||
use super::{CmdError, CmdResult, Document, InlineObject, Selection, Session, Source, StoryRef, Value, delete_selection, json, p, sort_key};
|
||||
|
||||
@@ -14,18 +14,19 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
|
||||
(1u32..).map(|n| format!("{base}{n}")).find(|t| !taken(t)).unwrap_or(base)
|
||||
}
|
||||
|
||||
/// The author of a source for matching untagged entries: the editor when there is no author.
|
||||
/// The author of a source for matching untagged entries: the corporate author (which takes the
|
||||
/// author's place when a source is saved), else the author, else the editor.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn by_author(x: &Source) -> &str {
|
||||
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
|
||||
[x.corporate.trim(), x.author.as_str(), x.editor.as_str()].into_iter().find(|w| !w.is_empty()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The stem of a new source's tag, before `1`, `2`… are added while it is taken: the first three
|
||||
/// alphanumerics of the first author's name (nothing of the author's, `Src`) and the last two
|
||||
/// digits of the year.
|
||||
/// alphanumerics of the first author's name or the corporate author (nothing of the author's,
|
||||
/// `Src`) and the last two digits of the year.
|
||||
fn auto_tag_base(src: &Source) -> String {
|
||||
let name: String =
|
||||
authors(&src.author).first().map(|x| x.0.clone()).unwrap_or_default().chars().filter(|c| c.is_alphanumeric()).take(3).collect();
|
||||
let first = corporate(src).map(str::to_string).or_else(|| authors(&src.author).first().map(|x| x.0.clone())).unwrap_or_default();
|
||||
let name: String = first.chars().filter(|c| c.is_alphanumeric()).take(3).collect();
|
||||
let name = if name.is_empty() { "Src".to_string() } else { name };
|
||||
let digits: Vec<char> = src.year.chars().filter(char::is_ascii_digit).collect();
|
||||
let year: String = digits.get(digits.len().saturating_sub(2)..).unwrap_or_default().iter().collect();
|
||||
@@ -184,8 +185,13 @@ fn cited_tags(doc: &Document) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A source's place in an author-sorted bibliography: the corporate author, else the first
|
||||
/// author's surname, else the title.
|
||||
fn author_key(src: &Source) -> String {
|
||||
authors(&src.author).first().map(|a| a.0.to_lowercase()).unwrap_or_else(|| src.title.to_lowercase())
|
||||
corporate(src)
|
||||
.map(str::to_lowercase)
|
||||
.or_else(|| authors(&src.author).first().map(|a| a.0.to_lowercase()))
|
||||
.unwrap_or_else(|| src.title.to_lowercase())
|
||||
}
|
||||
|
||||
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
|
||||
|
||||
@@ -103,8 +103,10 @@ pub static STYLES: [CiteStyle; 12] = [
|
||||
et_al_from: 3,
|
||||
order: Order::Author,
|
||||
names: names(Name::LastInitials, Name::LastInitials, ", ", ", & ", ", & "),
|
||||
book: "⟦{names} ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
|
||||
article: "⟦{names} ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
|
||||
// The names end with a full stop: an initial's, or one added after a group author or a
|
||||
// name without initials ("World Health Organization. (2021).", #4848).
|
||||
book: "⟦{names}. ⟧({year}). {title}.⟦ {publisher}.⟧⟦ {url}⟧",
|
||||
article: "⟦{names}. ⟧({year}). {title}. {journal}⟦, {volume}⟧⟦, {pages}⟧.⟦ {url}⟧",
|
||||
bib_title: "References",
|
||||
},
|
||||
CiteStyle {
|
||||
|
||||
@@ -18,6 +18,49 @@ fn citations_and_bibliography() {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
|
||||
}
|
||||
|
||||
/// #4848: a corporate author is cited and listed as written, in every style: never turned into
|
||||
/// a surname and initials ("Organization, W. H."), never left out for the title. Its tag and its
|
||||
/// place in an author-sorted list come from the name too.
|
||||
#[test]
|
||||
fn a_corporate_author_is_cited_and_listed_as_written() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "Hearing loss is common "})).unwrap();
|
||||
let who = "World Health Organization";
|
||||
s.run("references.citation", &json!({"source": {"kind": "report", "corporate": who, "title": "World report on hearing", "year": "2021", "publisher": "WHO Press", "city": "Geneva"}}))
|
||||
.unwrap();
|
||||
s.run("text.insert", &json!({"text": " and rising "})).unwrap();
|
||||
s.run("references.citation", &json!({"source": {"author": "Adams, Kim", "title": "Quiet Rooms", "year": "2019"}})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.bibliography", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.sources[0].tag, "Wor21", "the tag starts with the corporate name");
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(t.contains(&format!("({who}, 2021)")), "{t}");
|
||||
assert!(t.contains(&format!("{who}. (2021). World report on hearing. WHO Press.")), "{t}");
|
||||
// Sorted by the corporate name: Adams before World.
|
||||
let adams = t.find("Adams, K. (2019)").unwrap_or(usize::MAX);
|
||||
let world = t.find(&format!("{who}. (2021)")).unwrap_or(0);
|
||||
assert!(adams < world, "{t}");
|
||||
for style in [
|
||||
"MLA",
|
||||
"Chicago",
|
||||
"GB/T 7714",
|
||||
"GOST (name sort)",
|
||||
"GOST (title sort)",
|
||||
"ISO 690 (author-date)",
|
||||
"ISO 690 (numerical)",
|
||||
"Harvard (Anglia)",
|
||||
"Turabian",
|
||||
"IEEE",
|
||||
"SIST02",
|
||||
] {
|
||||
s.run("references.citationStyle", &json!({"style": style})).unwrap();
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
let entry = t.lines().find(|l| l.contains("World report on hearing")).unwrap_or_default();
|
||||
assert!(entry.contains(who), "{style}: {entry}");
|
||||
assert!(!entry.contains("Organization, W") && !entry.contains("ORGANIZATION, W"), "{style}: {entry}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Updating fields in a document without generated lists leaves its styles alone: a new
|
||||
/// style would make the next layout start over from nothing.
|
||||
#[test]
|
||||
@@ -162,6 +205,49 @@ fn caption_labels_do_not_need_a_digit() {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// #4411: captions restyled with a style based on Caption, directly (`Caption Table`) or through
|
||||
/// another (`Caption Table Wide`), are still captions after a DOCX save and reopen: every `tableX`
|
||||
/// caption is a cross-reference target and in the table of figures, and a Table caption is a
|
||||
/// Table target. Captions in a style not based on Caption are not, nor are their labels.
|
||||
#[test]
|
||||
fn captions_in_a_style_based_on_caption() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("T1\nT2\nT3\nT4\nT5\nNote\nGrid\nStone\nSee "));
|
||||
let caption = |s: &mut Session, text: &str, label: &str, title: &str| {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("references.caption", &json!({"label": label, "text": title})).unwrap();
|
||||
};
|
||||
for k in 1..=5 {
|
||||
caption(&mut s, &format!("T{k}"), "tableX", &format!("Row {k}"));
|
||||
}
|
||||
caption(&mut s, "Note", "tableX", "Aside");
|
||||
caption(&mut s, "Grid", "Table", "Grid");
|
||||
caption(&mut s, "Stone", "Plate", "Stone");
|
||||
s.run("styles.create", &json!({"name": "Caption Table", "basedOn": "Caption", "fromSelection": false})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Caption Table Wide", "basedOn": "Caption Table", "fromSelection": false})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Aside", "fromSelection": false})).unwrap();
|
||||
let style = |s: &Session, name: &str| s.doc.styles.find(name).map(|st| st.id.clone());
|
||||
let (table, wide, aside) = (style(&s, "Caption Table"), style(&s, "Caption Table Wide"), style(&s, "Aside"));
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let p = s.doc.para_mut(StoryRef::Body, &path).unwrap();
|
||||
let text = p.plain_text();
|
||||
p.props.style = match text.get(..8).unwrap_or_default() {
|
||||
"tableX 1" | "tableX 2" => table.clone(),
|
||||
"tableX 3" | "Table 1:" => wide.clone(),
|
||||
"tableX 6" | "Plate 1:" => aside.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
}
|
||||
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
|
||||
let mut s = Session::new(wordcraft_docx::read(&bytes).expect("docx read"));
|
||||
let want: Vec<String> = (1..=5).map(|k| format!("tableX {k}: Row {k}")).collect();
|
||||
assert_eq!(s.run("insert.crossReference", &json!({"to": "label:tableX"})).unwrap(), json!(want));
|
||||
assert_eq!(s.run("insert.crossReference", &json!({"to": "table"})).unwrap(), json!(["Table 1: Grid"]));
|
||||
assert_eq!(caption_labels(&s), ["tableX", "Table"]);
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.tableOfFigures", &json!({"label": "tableX", "pageNumbers": false})).unwrap();
|
||||
assert_eq!(list_entries(&s).into_iter().map(|(t, _)| t).collect::<Vec<_>>(), want);
|
||||
}
|
||||
|
||||
/// The entries of every generated list, in order, with their last tab stop's leader.
|
||||
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
|
||||
s.doc
|
||||
|
||||
@@ -180,6 +180,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
||||
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
|
||||
// from it without clearing it, as in Word.
|
||||
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
|
||||
// Copy and Cut of whole cells take a table of them (#1744).
|
||||
"edit.copy" | "edit.cut" if cells => run(s, v),
|
||||
"edit.copy" => copy(s, &segs),
|
||||
"edit.cut" => {
|
||||
let r = copy(s, &segs)?;
|
||||
|
||||
@@ -48,10 +48,10 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
|
||||
let (mut ids, mut conns, mut groups) = (HashMap::new(), Vec::new(), Vec::new());
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for off in para.object_offsets() {
|
||||
for (off, obj) in para.objects_with_offsets() {
|
||||
let pos = Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
match para.object_at(off) {
|
||||
Some(InlineObject::Shape { kind, extra, float, .. }) => {
|
||||
match obj {
|
||||
InlineObject::Shape { kind, extra, float, .. } => {
|
||||
if extra.id != 0 {
|
||||
ids.entry(extra.id).or_insert(pos.clone());
|
||||
}
|
||||
@@ -59,7 +59,7 @@ fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
|
||||
conns.push(pos);
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Group { children, .. })
|
||||
InlineObject::Group { children, .. }
|
||||
if children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { kind, extra, .. } if kind.is_connector() && (extra.start.is_some() || extra.end.is_some()))) =>
|
||||
{
|
||||
groups.push(pos)
|
||||
@@ -91,10 +91,11 @@ pub fn reroute(s: &mut Session) -> bool {
|
||||
s.touch();
|
||||
}
|
||||
let layout = s.layout();
|
||||
let objects = layout.object_index();
|
||||
let site = |e: Option<ConnEnd>, page: usize| -> Option<(f32, f32)> {
|
||||
let e = e?;
|
||||
let target = ids.get(&e.id)?;
|
||||
let hit = layout.object(target, page)?;
|
||||
let hit = objects.object(target, page)?;
|
||||
let kind = match s.doc.para_at(target)?.object_at(target.off)? {
|
||||
InlineObject::Shape { kind, .. } => *kind,
|
||||
_ => return None,
|
||||
@@ -103,7 +104,7 @@ pub fn reroute(s: &mut Session) -> bool {
|
||||
};
|
||||
let mut plans = Vec::new();
|
||||
for pos in conns {
|
||||
let Some(hit) = layout.object(&pos, s.page_hint) else { continue };
|
||||
let Some(hit) = objects.object(&pos, s.page_hint) else { continue };
|
||||
let Some(InlineObject::Shape { extra, float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue };
|
||||
let r = hit.rect;
|
||||
let (s0, e0) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v);
|
||||
@@ -313,6 +314,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawing commands must not search every page again for each glued connector.
|
||||
#[test]
|
||||
fn rerouting_many_pages_does_not_rescan_all_objects_per_connector() {
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
let mut doc = Document::new();
|
||||
doc.body = (0..8000u32)
|
||||
.map(|i| {
|
||||
let mut p = Paragraph::default();
|
||||
p.props.page_break_before = Some(i > 0);
|
||||
let id = i * 2 + 1;
|
||||
for (kind, x, extra) in [
|
||||
(ShapeKind::Rectangle, 100.0, ShapeExtra { id, ..Default::default() }),
|
||||
(ShapeKind::Ellipse, 300.0, ShapeExtra { id: id + 1, ..Default::default() }),
|
||||
(
|
||||
ShapeKind::ElbowConnector,
|
||||
0.0,
|
||||
ShapeExtra { start: Some(ConnEnd { id, site: 3 }), end: Some(ConnEnd { id: id + 1, site: 2 }), ..Default::default() },
|
||||
),
|
||||
] {
|
||||
let float = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y: 200.0, ..Default::default() };
|
||||
p.insert_object(p.len(), shape(kind, extra, float), &Default::default()).unwrap();
|
||||
}
|
||||
para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let mut s = Session::new(doc);
|
||||
s.view.proofing = false;
|
||||
s.page_hint = 0;
|
||||
assert_eq!(s.layout().pages.len(), 8000);
|
||||
let started = std::time::Instant::now();
|
||||
assert!(reroute(&mut s));
|
||||
let elapsed = started.elapsed();
|
||||
eprintln!("8000-page reroute: {elapsed:?}");
|
||||
assert!(elapsed < std::time::Duration::from_secs(5), "reroute took {elapsed:?}");
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let para = s.doc.para(StoryRef::Body, &path).unwrap();
|
||||
let InlineObject::Shape { w, h, float, .. } = para.object_at(6).unwrap() else { panic!("connector") };
|
||||
assert_eq!((*w, *h, float.x, float.y), (160.0, 0.0, 140.0, 210.0));
|
||||
}
|
||||
s.touch();
|
||||
assert!(!reroute(&mut s), "rerouting must settle after refreshing the layout");
|
||||
}
|
||||
|
||||
/// The per-case shape/geometry of one scaled-group reroute case.
|
||||
struct ScaledGroupSpec {
|
||||
w: f32,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
use wordcraft_doc::{Path, Pos, StoryRef};
|
||||
|
||||
use super::{delete_selection, find_pattern, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
@@ -27,10 +27,16 @@ fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
/// Something the Spike can collect: text (it doesn't take empty cells).
|
||||
fn has_text_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
|
||||
/// Something to cut: text, or whole table cells (an empty cell's selection has no length, #1744).
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() && s.cell_range().is_none() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
|
||||
@@ -63,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
|
||||
// Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick
|
||||
// Parts and AutoText insert it without emptying it, as in Word.
|
||||
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel),
|
||||
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_text_sel),
|
||||
CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| {
|
||||
if s.math.is_some() {
|
||||
Some("leave the equation first")
|
||||
@@ -167,11 +173,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let whole = super::table::selected_table(s);
|
||||
copy_with(s, whole.as_ref())
|
||||
}
|
||||
|
||||
/// Copy, `whole` being the table the selection covers whole, if it does.
|
||||
fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
return Ok(json!({"text": ""}));
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
// Whole table cells selected copy as a table, even empty ones (their selection has no
|
||||
// length): the whole table, or a table of just the selected rows or cells (#1744).
|
||||
let table = match (whole, s.cell_range()) {
|
||||
(Some(tp), _) => s.doc.copy_block(a.story, tp),
|
||||
(None, Some(r)) => s.doc.copy_table_cells(r.story, &r.table, r.rows, r.cols),
|
||||
(None, None) => None,
|
||||
};
|
||||
let f = match table {
|
||||
Some(f) => f,
|
||||
None if a == b => return Ok(json!({"text": ""})),
|
||||
None => s.doc.copy_range(&a, &b),
|
||||
};
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
@@ -200,8 +220,26 @@ pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
r
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = copy(s, v)?;
|
||||
fn cut(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let whole = super::table::selected_table(s);
|
||||
let r = copy_with(s, whole.as_ref())?;
|
||||
// A whole table selected: Cut removes the table, as Delete Table does (#1744). With Track
|
||||
// Changes on, the cut below marks its text deleted (a tracked cut may become a move).
|
||||
if !s.doc.settings.track_changes
|
||||
&& let Some(tp) = &whole
|
||||
{
|
||||
super::table::delete_table_at(s, tp)?;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
return Ok(r);
|
||||
}
|
||||
// Whole rows or some cells: their text goes, the rows and cells stay, as in Word.
|
||||
if whole.is_none()
|
||||
&& let Some(range) = s.cell_range().cloned()
|
||||
{
|
||||
super::table::clear_range_cells(s, &range)?;
|
||||
super::controls::restore_placeholder(s)?;
|
||||
return Ok(r);
|
||||
}
|
||||
// A tracked cut of a paragraph or sentence may become a move when pasted.
|
||||
if !super::moves::cut_tracked(s)? {
|
||||
delete_selection(s)?;
|
||||
@@ -738,7 +776,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
});
|
||||
if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {
|
||||
let l = s.layout();
|
||||
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
|
||||
let idx = crate::p::page_index(pg).min(l.pages.len().saturating_sub(1));
|
||||
let target = l.pages.get(idx).and_then(|page| {
|
||||
page.items.iter().find_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { story, path, para, l0, .. } if *story == StoryRef::Body => {
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
|
||||
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
|
||||
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | {"value": string, "linkTarget"?: string | null} | null (removes)}}; text values and bookmark targets are at most 1 MiB; a string keeps an existing target, an object replaces it (omitted/null target unlinks)"#),
|
||||
CommandSpec::new("file.info", "Info", "File", info).pure(),
|
||||
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
|
||||
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
|
||||
@@ -428,7 +428,7 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.push_ui_request(json!({"open": "exportPng"}));
|
||||
return Ok(json!({"exported": false}));
|
||||
};
|
||||
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
|
||||
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
|
||||
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
|
||||
let l = s.export_layout_with(export_options(s, v).markup);
|
||||
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
|
||||
@@ -445,6 +445,15 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
|
||||
}
|
||||
|
||||
/// Text and its bookmark association cross the dialog/command boundary together on rename.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct LinkedCustomText<'a> {
|
||||
value: &'a str,
|
||||
#[serde(borrow)]
|
||||
link_target: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = &mut s.doc.core;
|
||||
let set = |dst: &mut String, k: &str| {
|
||||
@@ -460,7 +469,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
set(&mut c.category, "category");
|
||||
if let Some(custom) = v.get("custom") {
|
||||
let Some(m) = custom.as_object() else {
|
||||
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
|
||||
return Err(CmdError::Params("`custom` must be an object of name → text, linked text value, or null".into()));
|
||||
};
|
||||
// Removals first: a case-only rename sends the old spelling as null and the new one
|
||||
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
|
||||
@@ -471,17 +480,32 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
}
|
||||
for (name, val) in m {
|
||||
match val {
|
||||
// Removals took the name as stored above: a file opened from an older Word may
|
||||
// hold one longer than today's limit, and it must still be deletable (#2982).
|
||||
Value::Null => {}
|
||||
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
|
||||
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
|
||||
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
|
||||
}
|
||||
s.doc.set_custom_prop(name, x);
|
||||
let bad_value =
|
||||
|| CmdError::Params(format!("custom property `{name}` must be text (≤ {MAX_CUSTOM_VALUE} bytes), a linked text value, or null"));
|
||||
let (value, target) = match val {
|
||||
// Removal also accepts names longer than today's limit (#2982).
|
||||
Value::Null => continue,
|
||||
Value::String(value) => (value.as_str(), None),
|
||||
Value::Object(_) => {
|
||||
let linked: LinkedCustomText<'_> = serde::Deserialize::deserialize(val).map_err(|_| bad_value())?;
|
||||
(linked.value, Some(linked.link_target))
|
||||
}
|
||||
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
|
||||
_ => return Err(bad_value()),
|
||||
};
|
||||
if value.len() > MAX_CUSTOM_VALUE {
|
||||
return Err(bad_value());
|
||||
}
|
||||
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
|
||||
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
|
||||
}
|
||||
if target.flatten().is_some_and(|t| t.len() > MAX_CUSTOM_VALUE) {
|
||||
return Err(CmdError::Params(format!("custom property bookmark targets are at most {MAX_CUSTOM_VALUE} bytes")));
|
||||
}
|
||||
s.doc.set_custom_prop(name, value);
|
||||
if let Some(target) = target
|
||||
&& let Some(prop) = s.doc.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
prop.link_target = target.map(str::to_string);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -495,7 +519,7 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
|
||||
/// too (#2982).
|
||||
pub const MAX_CUSTOM_NAME: usize = 255;
|
||||
/// Largest custom property value we accept from a command. The Custom Properties dialog
|
||||
/// Largest custom property value or bookmark target we accept from a command. The Custom Properties dialog
|
||||
/// validates with it too (#2982).
|
||||
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
|
||||
|
||||
|
||||
@@ -197,6 +197,17 @@ fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
|
||||
/// Apply a change to the selection (or the pending caret formatting).
|
||||
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
apply_to(s, target, f)
|
||||
}
|
||||
|
||||
/// The selection only, not the word at a caret: what highlighting formats (#4086).
|
||||
fn selection(s: &Session) -> Option<(Pos, Pos)> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
(a != b).then_some((a, b))
|
||||
}
|
||||
|
||||
/// [`apply`], with `target` choosing what a non-column selection formats.
|
||||
fn apply_to(s: &mut Session, target: fn(&Session) -> Option<(Pos, Pos)>, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
// A column selection formats each row's piece.
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
@@ -211,6 +222,12 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
match selected_text_box(s).or_else(|| target(s)) {
|
||||
Some((a, b)) => {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
// The word at a caret took the change: so does formatting waiting for typing (#4086).
|
||||
if s.sel.is_collapsed()
|
||||
&& let Some(pend) = s.pending.as_mut()
|
||||
{
|
||||
f(pend);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut pend = s.typing_props();
|
||||
@@ -354,26 +371,59 @@ fn current_size(s: &Session) -> f32 {
|
||||
}
|
||||
|
||||
fn step_size(s: &mut Session, dir: i32) -> CmdResult {
|
||||
let cur = current_size(s);
|
||||
let next = if dir > 0 {
|
||||
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
|
||||
} else {
|
||||
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
|
||||
};
|
||||
let next = next.clamp(1.0, 1638.0);
|
||||
// Each run steps from its own size when the selection mixes sizes.
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = Some(next);
|
||||
resize(s, &|cur| {
|
||||
if dir > 0 {
|
||||
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
|
||||
} else {
|
||||
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
|
||||
let next = (current_size(s) + d).clamp(1.0, 1638.0);
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = Some(next);
|
||||
})
|
||||
resize(s, &|cur| cur + d)
|
||||
}
|
||||
|
||||
/// Relative commands transform each run's effective size, including inherited sizes. Keep
|
||||
/// `apply`'s targets and the existing explicit override of both normal and complex-script size.
|
||||
fn resize(s: &mut Session, next: &dyn Fn(f32) -> f32) -> CmdResult {
|
||||
let set = |c: &mut CharProps, size: f32| {
|
||||
let size = next(size).clamp(1.0, 1638.0);
|
||||
c.size = Some(size);
|
||||
c.size_cs = Some(size);
|
||||
};
|
||||
let ranges = column_rows(s).or_else(|| selected_text_box(s).or_else(|| target(s)).map(|range| vec![range]));
|
||||
let Some(ranges) = ranges else {
|
||||
// Pending typing props and an empty paragraph's mark get the same single change.
|
||||
let size = current_size(s);
|
||||
return apply(s, &|c| set(c, size));
|
||||
};
|
||||
// Resolve against one immutable snapshot while formatting splits and normalizes runs.
|
||||
let styles = s.doc.styles.clone();
|
||||
for (a, b) in ranges {
|
||||
for path in s.doc.paths_between(&a, &b) {
|
||||
let p = s.doc.para_mut(a.story, &path)?;
|
||||
let style = p.props.style.clone();
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
let format = |c: &mut CharProps| set(c, styles.resolve_char(style.as_deref(), c).size);
|
||||
// Avoid format_range here: its path walk would repeat a paragraph-prefix scan.
|
||||
p.format(from, to, &format)?;
|
||||
// Keep format_range's paragraph-mark rule, including filtering linked marks.
|
||||
if to == p.len() && (from == 0 || path != a.path) {
|
||||
let style = p.mark.style.clone();
|
||||
format(&mut p.mark);
|
||||
if p.mark.link.take().is_some() {
|
||||
p.mark.style = style.filter(|s| s != "Hyperlink");
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty selected cell has only its paragraph mark, which needs a new layout too.
|
||||
if a == b {
|
||||
s.doc.para_mut(a.story, &a.path)?.touch();
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -410,7 +460,7 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
|
||||
if let Some((a, b)) = selected_text_box(s).or_else(|| selection(s)) {
|
||||
for path in s.doc.paths_between(&a, &b) {
|
||||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
@@ -422,7 +472,12 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
|
||||
// No runs: retain the caret/pending and empty text-box paragraph-mark rules. A caret checks
|
||||
// what is typed next, not the word it's in (#4086).
|
||||
if selected_text_box(s).or_else(|| selection(s)).is_none() {
|
||||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
|
||||
return seen || matches(style, &s.typing_props());
|
||||
}
|
||||
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
|
||||
}
|
||||
|
||||
@@ -472,7 +527,8 @@ fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let remove = color.is_none() && target_has_highlight(s, h);
|
||||
let value = if remove { Highlight::None } else { h };
|
||||
let result = apply(s, &|x| x.highlight = Some(value))?;
|
||||
// A caret highlights what is typed next, not the word it's in, as in Word (#4086).
|
||||
let result = apply_to(s, selection, &|x| x.highlight = Some(value))?;
|
||||
s.last_highlight = h;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use super::filter::refresh_shown;
|
||||
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.
|
||||
/// The field separator: whichever of `,` `;` and tab the first non-blank line (outside quotes) uses most.
|
||||
fn csv_separator(text: &str) -> char {
|
||||
let first = text.lines().next().unwrap_or("");
|
||||
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
|
||||
let mut quoted = false;
|
||||
let mut unquoted = String::new();
|
||||
for c in first.chars() {
|
||||
|
||||
@@ -52,6 +52,13 @@ fn csv_delimiter_ignores_quoted_text() {
|
||||
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_delimiter_skips_leading_blank_lines() {
|
||||
let want = vec![vec!["Name", "Email"], vec!["Ann", "ann@x.org"]];
|
||||
assert_eq!(parse_csv("\nName,Email\nAnn,ann@x.org\n"), want);
|
||||
assert_eq!(parse_csv("\r\n\r\nName;Email\r\nAnn;ann@x.org"), want);
|
||||
}
|
||||
|
||||
/// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field.
|
||||
#[test]
|
||||
fn merge_field_types_ignore_case() {
|
||||
|
||||
@@ -508,9 +508,21 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
|
||||
let page =
|
||||
l.caret(&Pos { story: StoryRef::Body, path: target, off: e.lead }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
|
||||
let show_page = no_pages.is_none_or(|(a, b)| !(a..=b).contains(&(e.level + 1)));
|
||||
let text = if show_page { format!("{}{sep}{page}", e.text) } else { e.text.clone() };
|
||||
let props = CharProps { link: links.get(k).cloned().flatten(), ..Default::default() };
|
||||
let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1));
|
||||
let link = links.get(k).cloned().flatten();
|
||||
// With a bookmark to point at, the page number is a `PAGEREF` field, as Word writes
|
||||
// it, so updating it in Word follows the heading to its new page.
|
||||
let pageref = link.as_deref().and_then(|l| l.strip_prefix('#')).filter(|_| show_page).map(|b| format!("PAGEREF {b} \\h"));
|
||||
let text = match (show_page, &pageref) {
|
||||
(true, None) => format!("{}{sep}{page}", e.text),
|
||||
(true, Some(_)) => format!("{}{sep}", e.text),
|
||||
(false, _) => e.text.clone(),
|
||||
};
|
||||
let props = CharProps { link, ..Default::default() };
|
||||
let mut para = Paragraph::with_text(&text, props.clone()).styled(&format!("TOC{}", e.level + 1));
|
||||
if let Some(instr) = pageref {
|
||||
let field = InlineObject::Field { instr, result: page.to_string(), locked: false, dirty: false, code: false, form: String::new() };
|
||||
para.insert_object(para.len(), field, &props)?;
|
||||
}
|
||||
para.mark = CharProps::default();
|
||||
if e.text.contains('\t') {
|
||||
para.props.tabs = tabs.iter().find(|(style, _)| Some(style) == para.props.style.as_ref()).map(|(_, tabs)| tabs.clone());
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(super) use cells::grid_cols;
|
||||
pub use cells::selected_cells;
|
||||
use cells::{cell, with_cells, with_table};
|
||||
pub(crate) use cells::{cell_text, row_ends};
|
||||
pub(super) use clear::clear_range_cells;
|
||||
pub(crate) use clear::{backspace_cells, clear_cells};
|
||||
use clear::{cell_prefix, guard_delete, text_spans};
|
||||
use convert::{formula, from_text, quick_table, sort_table, to_text};
|
||||
@@ -31,6 +32,7 @@ use select::{select_cells, select_column};
|
||||
pub use structure::split_targets;
|
||||
pub(super) use structure::{col, row};
|
||||
use structure::{del_col, del_row, del_table, merge, split, split_table};
|
||||
pub(super) use structure::{delete_table_at, selected_table};
|
||||
|
||||
fn in_table(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") }
|
||||
@@ -223,7 +225,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"value"?: bool} (true: the first column stands at the right; without a value it toggles)"#)),
|
||||
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", properties).params(
|
||||
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the caret's; without settings it returns the current ones)"#,
|
||||
r#"{"align"?: "left|center|right", "rtl"?: bool, "width"?: pt|null, "widthPct"?: percent, "indent"?: pt, "rowHeight"?: pt|null, "rowHeightRule"?: "atLeast|exact", "allowBreak"?: bool, "headerRow"?: bool, "columnWidth"?: pt, "cellWidth"?: pt|null, "valign"?: "top|center|bottom"} (row, column and cell settings apply to the selected rows, columns and cells, or the caret's; without settings it returns the caret's current ones)"#,
|
||||
)),
|
||||
CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text)
|
||||
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other), "columns"?: n, "autofit"?: "initial|fixed|contents|window", "width"?: pt (initial column width, 0 = Auto, at most 1584)}"#),
|
||||
|
||||
@@ -30,6 +30,30 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Clear the text of the cells `range` selects, leaving the cells (Cut of rows or cells, #1744);
|
||||
/// tracked when Track Changes is on. The caret goes to the first cell.
|
||||
pub(in crate::cmd) fn clear_range_cells(s: &mut Session, range: &crate::CellRange) -> Result<(), CmdError> {
|
||||
let Some(t) = s.doc.table(range.story, &range.table) else { return Ok(()) };
|
||||
// The cells Copy takes: columns widened over merged cells, as `Table::slice` does.
|
||||
let (lo, hi, any) = t.slice_bounds(range.rows, range.cols);
|
||||
let cells = if any { crate::CellRange { cols: (lo, hi.saturating_sub(1)), ..range.clone() }.cells(t) } else { Vec::new() };
|
||||
let prefixes: Vec<Vec<u32>> = cells.into_iter().map(|(r, c)| cell_prefix(&range.table, r, c)).collect();
|
||||
let spans = text_spans(s, range.story, &prefixes);
|
||||
let first = spans.first().map(|(a, _)| a.clone());
|
||||
if guard_delete(s, &spans)?.is_none() {
|
||||
for (a, b) in spans.iter().rev() {
|
||||
if a != b {
|
||||
s.doc.delete_range(a, b)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.column = None;
|
||||
if let Some(a) = first {
|
||||
s.sel = Selection::caret(s.doc.clamp(&a));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
|
||||
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
|
||||
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::cells::{cell, with_cells, with_table};
|
||||
use super::cells::{cell, selected_cells, with_cells, with_table};
|
||||
use super::{
|
||||
Align, Border, BorderStyle, Borders, CmdError, CmdResult, HeightRule, Path, Pos, Rgb, Selection, Session, Table, TextDirection, VAlign, Value,
|
||||
json, p, sel_result,
|
||||
@@ -74,18 +74,9 @@ pub(super) fn set_column_widths(t: &mut Table, cells: &[(usize, usize)], w: f32)
|
||||
}
|
||||
}
|
||||
|
||||
fn set_column_width(t: &mut Table, r: usize, c: usize, w: f32) {
|
||||
let g = t.grid_col(r, c);
|
||||
if let Some(x) = t.grid.get_mut(g) {
|
||||
*x = w;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
sync_cell_widths(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Table Properties: table, row, column and cell settings in one undoable step. Row, column and
|
||||
/// cell settings apply to the caret's. Without any setting it returns the current values.
|
||||
/// cell settings apply to the selected rows, columns and cells, as in Word, and to the caret's
|
||||
/// when nothing is selected (#4857). Without any setting it returns the caret's current values.
|
||||
pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let keys = [
|
||||
@@ -150,6 +141,11 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let column_width = p::f32(v, "columnWidth").map(|x| x.clamp(6.0, 1584.0));
|
||||
let cell_width = size("cellWidth", 6.0, 1584.0);
|
||||
let rtl = p::bool(v, "rtl");
|
||||
// The selected cells (a whole table, rows, a column…), else the caret's.
|
||||
let cells = Some(selected_cells(s)).filter(|c| !c.is_empty()).unwrap_or_else(|| vec![(r, c)]);
|
||||
let mut rows: Vec<usize> = cells.iter().map(|&(r, _)| r).collect();
|
||||
rows.sort_unstable();
|
||||
rows.dedup();
|
||||
with_table(s, |t| {
|
||||
if let Some(a) = align {
|
||||
t.props.align = Some(a);
|
||||
@@ -161,7 +157,7 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
t.props.indent = Some(i);
|
||||
}
|
||||
if let Some(cw) = column_width {
|
||||
set_column_width(t, r, c, cw);
|
||||
set_column_widths(t, &cells, cw);
|
||||
}
|
||||
if let Some(w) = width {
|
||||
t.props.width = w;
|
||||
@@ -170,7 +166,8 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(pct) = width_pct {
|
||||
t.props.width_pct = Some(pct);
|
||||
}
|
||||
if let Some(row) = t.rows.get_mut(r) {
|
||||
for &r in &rows {
|
||||
let Some(row) = t.rows.get_mut(r) else { continue };
|
||||
if let Some(h) = row_height {
|
||||
row.props.height = h;
|
||||
row.props.height_rule = if h.is_some() { rule.unwrap_or(HeightRule::AtLeast) } else { HeightRule::Auto };
|
||||
@@ -185,13 +182,14 @@ pub(super) fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(h) = header {
|
||||
row.props.header = h;
|
||||
}
|
||||
if let Some(cl) = row.cells.get_mut(c) {
|
||||
if let Some(w) = cell_width {
|
||||
cl.props.width = w;
|
||||
}
|
||||
if let Some(va) = valign {
|
||||
cl.props.valign = Some(va);
|
||||
}
|
||||
}
|
||||
for &(r, c) in &cells {
|
||||
let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) else { continue };
|
||||
if let Some(w) = cell_width {
|
||||
cl.props.width = w;
|
||||
}
|
||||
if let Some(va) = valign {
|
||||
cl.props.valign = Some(va);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::cells::{cell, selected_cells};
|
||||
use super::clear::{cell_prefix, guard_delete, text_spans};
|
||||
use super::layout::{fix_caret, sync_cell_widths};
|
||||
use super::{Block, CmdError, CmdResult, Paragraph, Pos, Selection, Session, Value, json, p, sel_result};
|
||||
use wordcraft_doc::Path;
|
||||
|
||||
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
|
||||
pub(in crate::cmd) fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
@@ -104,6 +105,25 @@ pub(super) fn del_col(s: &mut Session, _: &Value) -> CmdResult {
|
||||
|
||||
pub(super) fn del_table(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, _, _) = cell(s)?;
|
||||
delete_table_at(s, &tp)
|
||||
}
|
||||
|
||||
/// The table the selection covers whole (Select Table, a drag over all of it), if it does (#1744).
|
||||
pub(in crate::cmd) fn selected_table(s: &Session) -> Option<Path> {
|
||||
if let Some(r) = s.cell_range()
|
||||
&& let Some(t) = s.doc.table(r.story, &r.table)
|
||||
&& r.rows == (0, t.rows.len().saturating_sub(1))
|
||||
&& r.cols == (0, t.cols().saturating_sub(1))
|
||||
{
|
||||
return Some(r.table.clone());
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.whole_table(&a, &b)
|
||||
}
|
||||
|
||||
/// Delete Table for the table at `tp` (in the selection's story): tracked when Track Changes is on.
|
||||
pub(in crate::cmd) fn delete_table_at(s: &mut Session, tp: &Path) -> CmdResult {
|
||||
let tp = tp.clone();
|
||||
let story = s.sel.focus.story;
|
||||
let spans = text_spans(s, story, std::slice::from_ref(&tp.0));
|
||||
if let Some(done) = guard_delete(s, &spans)? {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::enter_tab::tab_indent_on;
|
||||
use super::{Block, CmdResult, NumRef, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
|
||||
use super::{Block, CmdResult, NumRef, OBJ, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result};
|
||||
|
||||
pub(super) fn backspace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let r = backspace_inner(s, v)?;
|
||||
@@ -43,8 +43,17 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
let mut f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
|
||||
// Only floating objects before the caret: they are anchors, not characters, so this is the
|
||||
// paragraph start and the shape stays (Word).
|
||||
let before = para.text.get(..f.off).unwrap_or_default();
|
||||
if !before.is_empty()
|
||||
&& before.chars().all(|c| c == OBJ)
|
||||
&& para.objects_with_offsets().take_while(|(o, _)| *o < f.off).all(|(_, o)| o.is_marker() || o.is_floating())
|
||||
{
|
||||
f.off = 0;
|
||||
}
|
||||
if f.off > 0 {
|
||||
let a = Pos { off: para.backspace_from(f.off), ..f.clone() };
|
||||
if let Some(c) = s.autocap.as_mut() {
|
||||
@@ -98,6 +107,12 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
delete_selection(s)?;
|
||||
return sel_result(s);
|
||||
}
|
||||
// Joining moves a content control whose start is at the paragraph edge. Check its
|
||||
// contents explicitly: the paragraph-mark range itself ends before those contents.
|
||||
for control in s.doc.control_ranges(f.story).into_iter().filter(|r| r.start.path == f.path && r.start.off == f.off) {
|
||||
crate::cmd::controls::check_edit(s, &control.content_start(), &control.end)?;
|
||||
}
|
||||
crate::cmd::controls::check_delete(s, &a, &f)?;
|
||||
let at = s.doc.delete_range(&a, &f)?;
|
||||
s.sel = Selection::caret(at);
|
||||
}
|
||||
|
||||
@@ -192,6 +192,11 @@ pub mod p {
|
||||
pub fn u64(v: &Value, k: &str) -> Option<u64> {
|
||||
v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64)))
|
||||
}
|
||||
/// A 1-based page parameter as a 0-based index. A value that cannot fit in
|
||||
/// `usize` (32-bit and wasm) saturates instead of wrapping to page 1.
|
||||
pub fn page_index(page: u64) -> usize {
|
||||
usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1)
|
||||
}
|
||||
pub fn bool(v: &Value, k: &str) -> Option<bool> {
|
||||
v.get(k).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
@@ -156,8 +156,20 @@ impl Session {
|
||||
}
|
||||
|
||||
/// The document as the PDF of a print job `v` describes (see [`plan`]), with the number of
|
||||
/// distinct pages chosen and copies. Does not touch the document.
|
||||
/// distinct pages chosen and copies, as File › Print › Save these pages as PDF keeps it:
|
||||
/// links work. Does not touch the document.
|
||||
pub fn print_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||||
self.print_pdf_with(v, true)
|
||||
}
|
||||
|
||||
/// The PDF sent to a printer for job `v`: [`Self::print_pdf`] without link annotations,
|
||||
/// which print nothing, and which a printer driver can't fit to the paper ("flatten page
|
||||
/// annotations before using shrink-to-fit", #4734).
|
||||
pub fn print_job_pdf(&self, v: &Value) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||||
self.print_pdf_with(v, false)
|
||||
}
|
||||
|
||||
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||||
// Print Markup (File › Print) decides whether tracked changes and comments print.
|
||||
let markup = crate::cmd::file::export_options(self, v);
|
||||
let lay = self.export_layout_with(markup.markup);
|
||||
@@ -170,6 +182,7 @@ impl Session {
|
||||
include_markup: markup.markup,
|
||||
comments: markup.comments,
|
||||
paper,
|
||||
links,
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
@@ -238,6 +251,36 @@ mod tests {
|
||||
assert!(r, "printPdf needs a path");
|
||||
}
|
||||
|
||||
/// #4734: the PDF sent to a printer has no link annotations, which a printer driver can't fit
|
||||
/// to the paper ("flatten page annotations before using shrink-to-fit"). An exported PDF and
|
||||
/// one saved with Save these pages as PDF (`file.printPdf`) keep them.
|
||||
#[test]
|
||||
fn only_the_print_job_leaves_out_link_annotations() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.link", &json!({"url": "https://example.com", "text": "a link"})).unwrap();
|
||||
let lay = s.export_layout();
|
||||
let exported = wordcraft_pdf::export_layout(&s.doc, &lay, &wordcraft_pdf::PdfOptions::default()).unwrap();
|
||||
let links = |bytes: &[u8]| {
|
||||
let raw = String::from_utf8_lossy(bytes);
|
||||
raw.contains("/Annots") && raw.contains("/Link")
|
||||
};
|
||||
assert!(links(&exported), "an exported PDF keeps the link");
|
||||
for params in [json!({}), json!({"paper": "A4"}), json!({"markup": true})] {
|
||||
let (job, _) = s.print_job_pdf(¶ms).unwrap();
|
||||
assert!(!links(&job) && !String::from_utf8_lossy(&job).contains("/Annots"), "{params}");
|
||||
let (saved, _) = s.print_pdf(¶ms).unwrap();
|
||||
assert!(links(&saved), "{params}");
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = std::env::temp_dir().join(format!("wc-print-links-{}.pdf", std::process::id()));
|
||||
s.run("file.printPdf", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(links(&saved), "Save these pages as PDF keeps the link");
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale to paper size (#2108): the PDF goes on the chosen paper; the document keeps its size.
|
||||
#[test]
|
||||
fn print_pdf_scales_to_the_chosen_paper() {
|
||||
|
||||
+495
-2
@@ -11,6 +11,7 @@ use crate::{Session, cmd};
|
||||
|
||||
mod bidi;
|
||||
mod documents;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
mod revision_checks;
|
||||
@@ -1943,6 +1944,291 @@ fn tables_commands() {
|
||||
assert!(s.run("table.merge", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A 2×2 table "A1 B1 / A2 B2" after an "Intro" paragraph, the caret in its first cell.
|
||||
fn table_doc() -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": t}));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn body_table_cells(s: &Session) -> Vec<Vec<String>> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_table())
|
||||
.map(|t| {
|
||||
t.rows
|
||||
.iter()
|
||||
.flat_map(|r| r.cells.iter())
|
||||
.map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>())
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #1744: Select Table → Copy → Paste outside the table pastes a copy of the whole table.
|
||||
#[test]
|
||||
fn copy_whole_table_pastes_a_table() {
|
||||
let mut s = table_doc();
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert!(r["text"].as_str().unwrap().contains("A1\tB1"), "clipboard text is tab separated: {r}");
|
||||
assert!(matches!(s.clipboard.as_ref().unwrap().blocks.as_slice(), [wordcraft_doc::Block::Table(_)]), "the copy is the table");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
let want = vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()];
|
||||
assert_eq!(body_table_cells(&s), vec![want.clone(), want]);
|
||||
}
|
||||
|
||||
/// #1744: Select Table → Cut removes the whole table, and one Undo brings it back.
|
||||
#[test]
|
||||
fn cut_whole_table_removes_it_and_undoes() {
|
||||
let mut s = table_doc();
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(body_table_cells(&s).is_empty(), "the table is gone");
|
||||
assert!(s.sel.focus.path.cell().is_none(), "the caret left the table");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()]]);
|
||||
// Paste puts it back as a table.
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(body_table_cells(&s).len(), 1);
|
||||
}
|
||||
|
||||
/// A 3×3 table "A1 B1 C1 / A2 B2 C2 / A3 B3 C3" after an "Intro" paragraph (#1744).
|
||||
fn table3_doc() -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
|
||||
for (i, t) in ["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": t}));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Select whole cells of the body's table: rows and grid columns, both inclusive.
|
||||
fn select_table_cells(s: &mut Session, rows: (usize, usize), cols: (usize, usize)) {
|
||||
let tp = wordcraft_doc::Path(vec![1]);
|
||||
crate::cmd::table::select_range(s, StoryRef::Body, &tp, rows, cols).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some((rows, cols)));
|
||||
}
|
||||
|
||||
fn row_cells(texts: &[&str]) -> Vec<String> {
|
||||
texts.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
/// #1744 (Word check A): copying whole rows, two cells of a row, or a block of cells over
|
||||
/// several rows, then pasting outside the table, pastes a table of just those cells.
|
||||
#[test]
|
||||
fn copy_rows_and_cells_pastes_a_table_of_them() {
|
||||
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
|
||||
for (rows, cols, want) in [
|
||||
((1, 1), (0, 2), row_cells(&["A2", "B2", "C2"])),
|
||||
((0, 0), (0, 1), row_cells(&["A1", "B1"])),
|
||||
((1, 2), (1, 2), row_cells(&["B2", "C2", "B3", "C3"])),
|
||||
] {
|
||||
let mut s = table3_doc();
|
||||
select_table_cells(&mut s, rows, cols);
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else {
|
||||
panic!("{rows:?} {cols:?}: the copy is a table, not {:?}", s.clipboard_text);
|
||||
};
|
||||
assert_eq!(t.rows.len(), rows.1 - rows.0 + 1, "{rows:?} {cols:?}");
|
||||
assert_eq!(t.grid.len(), cols.1 - cols.0 + 1, "{rows:?} {cols:?}: the grid has the copied columns");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![want, full.clone()], "{rows:?} {cols:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1744 (Word check A): Cut of whole rows, or of some cells, clears their text and leaves
|
||||
/// the rows and cells; the clipboard still gets a table of them. One Undo brings the text back.
|
||||
#[test]
|
||||
fn cut_rows_and_cells_clears_their_text() {
|
||||
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
|
||||
for (rows, cols, left, copied) in [
|
||||
((1, 1), (0, 2), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]), row_cells(&["A2", "B2", "C2"])),
|
||||
((0, 0), (0, 1), row_cells(&["", "", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]), row_cells(&["A1", "B1"])),
|
||||
((1, 2), (1, 2), row_cells(&["A1", "B1", "C1", "A2", "", "", "A3", "", ""]), row_cells(&["B2", "C2", "B3", "C3"])),
|
||||
] {
|
||||
let mut s = table3_doc();
|
||||
select_table_cells(&mut s, rows, cols);
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![left.clone()], "{rows:?} {cols:?}: rows and cells stay");
|
||||
assert!(s.sel.is_collapsed(), "{rows:?} {cols:?}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![full.clone()], "{rows:?} {cols:?}: one undo step");
|
||||
select_table_cells(&mut s, rows, cols);
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![copied, left], "{rows:?} {cols:?}: the cut pastes as a table");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1744 review: Cut clears every cell Copy takes: a cell merged across the selected columns
|
||||
/// widens them, so the cell below the merge's second column is copied and cleared too.
|
||||
#[test]
|
||||
fn cut_clears_the_cells_a_merged_cell_widens_the_copy_to() {
|
||||
let mut s = table3_doc();
|
||||
select_table_cells(&mut s, (0, 0), (0, 1));
|
||||
run(&mut s, "table.merge", json!({}));
|
||||
select_table_cells(&mut s, (0, 1), (0, 0));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
let cells = body_table_cells(&s).remove(0);
|
||||
assert_eq!(cells.len(), 8, "one merged cell and seven others: {cells:?}");
|
||||
assert_eq!(cells[1], "C1");
|
||||
assert!(cells[2..4].iter().all(String::is_empty), "A2 and B2 are cleared: {cells:?}");
|
||||
assert_eq!(cells[4..], ["C2", "A3", "B3", "C3"], "the rest stays");
|
||||
}
|
||||
|
||||
/// #1744 review: an empty cell's selection enables Cut but not the Spike, which takes text only.
|
||||
#[test]
|
||||
fn spike_stays_disabled_on_an_empty_cell_selection() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
select_table_cells(&mut s, (0, 0), (0, 0));
|
||||
let r = s.run("edit.spike", &json!({}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
|
||||
}
|
||||
|
||||
fn clip_is_table(s: &Session) -> bool {
|
||||
matches!(s.clipboard.as_ref().map(|f| f.blocks.as_slice()), Some([wordcraft_doc::Block::Table(_)]))
|
||||
}
|
||||
|
||||
/// The body table's cells as they read with every change accepted.
|
||||
fn body_table_final(s: &Session) -> Vec<String> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
|
||||
t.rows.iter().flat_map(|r| r.cells.iter()).map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.final_text()).collect()).collect()
|
||||
}
|
||||
|
||||
/// #1744 review: an empty table or cell selected whole still copies, and Cut of the empty
|
||||
/// table removes it, as in Word.
|
||||
#[test]
|
||||
fn copy_and_cut_of_an_empty_table_or_cell() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert!(clip_is_table(&s), "an empty 1×1 table copies: {:?}", s.clipboard);
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(body_table_cells(&s).is_empty(), "Cut removes the empty table");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_cells(&s), vec![vec![String::new()]]);
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "document.setText", json!({"text": "Intro"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
select_table_cells(&mut s, (0, 0), (0, 0));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert!(clip_is_table(&s), "an empty cell copies as a one-cell table");
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert_eq!(body_table_cells(&s).len(), 1, "Cut of an empty cell leaves the table");
|
||||
}
|
||||
|
||||
/// #1744 review: a text selection over exactly a whole table (no whole-cell record) copies the
|
||||
/// table; so does one over exactly a table nested in a cell. Other users of the copied range
|
||||
/// read it as a table too: the selected text is tab separated, a Quick Part saved from it
|
||||
/// inserts a table.
|
||||
#[test]
|
||||
fn text_selection_of_a_whole_table_copies_the_table() {
|
||||
let mut s = table_doc();
|
||||
let at = |path: Vec<u32>, off: usize| json!({"story": "body", "path": path, "off": off});
|
||||
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 0), "focus": at(vec![1, 1, 1, 0], 2)}));
|
||||
assert!(s.cell_range().is_none(), "a text selection");
|
||||
assert_eq!(s.selected_text(), "A1\tB1\nA2\tB2");
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert!(clip_is_table(&s));
|
||||
run(&mut s, "insert.quickParts", json!({"save": "grid"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "insert.quickParts", json!({"insert": "grid"}));
|
||||
assert_eq!(body_table_cells(&s).len(), 2, "the Quick Part is a table");
|
||||
|
||||
// A table nested in cell A1: selecting its text whole copies just it.
|
||||
let mut s = table_doc();
|
||||
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 2), "focus": at(vec![1, 0, 0, 0], 2)}));
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "N1"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "N2"}));
|
||||
let (inner, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
assert_eq!(inner.0.len(), 4, "nested in a cell");
|
||||
let first = [inner.0.clone(), vec![0, 0, 0]].concat();
|
||||
let last = [inner.0.clone(), vec![1, 0, 0]].concat();
|
||||
run(&mut s, "select.range", json!({"anchor": at(first, 0), "focus": at(last, 2)}));
|
||||
assert!(s.cell_range().is_none(), "a text selection");
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else { panic!("{:?}", s.clipboard_text) };
|
||||
assert_eq!(t.rows.len(), 2, "the nested table, not the outer one");
|
||||
}
|
||||
|
||||
/// #1744 review: with Track Changes on, Cut of the whole table or of a row marks the text
|
||||
/// deleted and keeps the table; one Undo takes the marks back.
|
||||
#[test]
|
||||
fn tracked_cut_of_a_table_or_row_marks_text_deleted() {
|
||||
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
|
||||
let mut s = table3_doc();
|
||||
s.doc.settings.track_changes = true;
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(clip_is_table(&s));
|
||||
assert_eq!(body_table_cells(&s), vec![full.clone()], "the table and its text stay, marked");
|
||||
assert!(body_table_final(&s).iter().all(String::is_empty), "{:?}", body_table_final(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_final(&s), full);
|
||||
|
||||
select_table_cells(&mut s, (1, 1), (0, 2));
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(clip_is_table(&s));
|
||||
assert_eq!(body_table_cells(&s), vec![full.clone()]);
|
||||
assert_eq!(body_table_final(&s), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(body_table_final(&s), full);
|
||||
}
|
||||
|
||||
/// #1744 review: Cut of a table, or of cells, holding a content control locked against deletion
|
||||
/// is refused and changes nothing in the document; the copy is made first, so the clipboard
|
||||
/// holds the table, as for any refused Cut.
|
||||
#[test]
|
||||
fn cut_of_cells_with_a_locked_control_is_refused() {
|
||||
for whole in [true, false] {
|
||||
let mut s = table3_doc();
|
||||
run(&mut s, "edit.find", json!({"text": "B2"}));
|
||||
run(&mut s, "developer.richText", json!({"tag": "keep"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "keep", "lockDelete": true}));
|
||||
if whole {
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
} else {
|
||||
select_table_cells(&mut s, (1, 1), (0, 2));
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
assert!(s.run("edit.cut", &json!({})).is_err(), "whole table: {whole}");
|
||||
assert_eq!(s.doc, before, "whole table: {whole}");
|
||||
assert!(s.status.contains("can't be deleted"), "{}", s.status);
|
||||
assert!(clip_is_table(&s), "whole table: {whole}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
|
||||
let mut s = s();
|
||||
@@ -2432,6 +2718,79 @@ fn table_properties_apply_in_one_step() {
|
||||
assert!(s.run("table.properties", &json!({"valign": "middle"})).is_err());
|
||||
}
|
||||
|
||||
/// #4857: Table Properties' row settings apply to every selected row (the whole table, say), the
|
||||
/// column width to every selected column and the cell settings to every selected cell, as in
|
||||
/// Word, in one undo step; with nothing selected, to the caret's alone.
|
||||
#[test]
|
||||
fn table_properties_apply_to_the_selected_rows_columns_and_cells() {
|
||||
use wordcraft_doc::props::VAlign;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||||
let table = |s: &Session| s.doc.body.iter().find_map(|b| b.as_table()).unwrap().clone();
|
||||
let cant_split = |s: &Session| table(s).rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>();
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false}));
|
||||
assert_eq!(cant_split(&s), [true, false, false], "the caret's row alone");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let before = table(&s);
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false, "rowHeight": 30.0, "columnWidth": 100.0, "cellWidth": 90.0, "valign": "center"}));
|
||||
let t = table(&s);
|
||||
assert_eq!(cant_split(&s), [true, true, true], "every selected row");
|
||||
assert!(t.rows.iter().all(|r| r.props.height == Some(30.0)));
|
||||
assert_eq!(t.grid, [100.0, 100.0], "every selected column");
|
||||
assert!(t.rows.iter().flat_map(|r| &r.cells).all(|c| c.props.width == Some(90.0) && c.props.valign == Some(VAlign::Center)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(table(&s), before, "one undo step");
|
||||
// A selected row: that row alone; a selected column: that column alone.
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "table.selectRow", json!({}));
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false}));
|
||||
assert_eq!(cant_split(&s), [false, true, false]);
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "table.selectColumn", json!({}));
|
||||
run(&mut s, "table.properties", json!({"columnWidth": 150.0}));
|
||||
assert_eq!(table(&s).grid.get(1), Some(&150.0));
|
||||
assert_ne!(table(&s).grid.first(), Some(&150.0));
|
||||
}
|
||||
|
||||
/// #4857: a tall row across a page break moves to the next page whole once the whole table is
|
||||
/// selected and "Allow row to break across pages" is turned off.
|
||||
#[test]
|
||||
fn a_selected_table_kept_from_breaking_moves_its_row_whole() {
|
||||
// Lines above the table until its 12-line second row starts across the page break.
|
||||
let found = (0..80u32).find_map(|above| {
|
||||
let mut s = s();
|
||||
for k in 0..above {
|
||||
run(&mut s, "text.insert", json!({"text": format!("Line {k}")}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "Kiln"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
for i in 0..12 {
|
||||
run(&mut s, "text.insert", json!({"text": format!("Press note {i}")}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
(row_pages(&mut s, above).len() == 2).then_some((s, above))
|
||||
});
|
||||
let (mut s, table) = found.expect("the row starts across the page break");
|
||||
run(&mut s, "table.selectTable", json!({}));
|
||||
run(&mut s, "table.properties", json!({"allowBreak": false}));
|
||||
assert_eq!(row_pages(&mut s, table), [1], "the row moves to the next page whole");
|
||||
}
|
||||
|
||||
/// The pages the second row of the table at body block `table` has lines on.
|
||||
fn row_pages(s: &mut Session, table: u32) -> Vec<usize> {
|
||||
let row = [table, 1];
|
||||
let on_page = |p: &wordcraft_layout::Page| {
|
||||
p.items.iter().any(|it| matches!(it, wordcraft_layout::Placed::Lines { path, .. } if path.0.get(..2) == Some(&row[..])))
|
||||
};
|
||||
s.layout().pages.iter().enumerate().filter(|(_, p)| on_page(p)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
|
||||
/// top-to-bottom and bottom-to-top, and the layout turns the text.
|
||||
#[test]
|
||||
@@ -3754,7 +4113,8 @@ fn update_toc_clears_stale_field_result_for_docx_span() {
|
||||
s
|
||||
};
|
||||
let b = xml.find(r#"w:fldCharType="begin""#).expect("TOC begin marker present");
|
||||
let e = xml.find(r#"w:fldCharType="end""#).expect("TOC end marker present");
|
||||
// The TOC's end is the last one: the entries' page numbers are `PAGEREF` fields inside it.
|
||||
let e = xml.rfind(r#"w:fldCharType="end""#).expect("TOC end marker present");
|
||||
assert!(b < e, "begin marker precedes end marker");
|
||||
let inside_field = &xml[b..e];
|
||||
let after_field = &xml[e..];
|
||||
@@ -3939,6 +4299,50 @@ fn toc_page_numbers_follow_headings() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hyperlinked TOC's page numbers are `PAGEREF` fields to the headings' bookmarks, as Word
|
||||
/// writes them, so updating them in Word follows the headings; they survive a save and reopen.
|
||||
#[test]
|
||||
fn toc_page_numbers_are_pageref_fields() {
|
||||
let mut s = s();
|
||||
run(&mut s, "references.toc", json!({}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
for name in ["Alpha", "Bravo"] {
|
||||
run(&mut s, "text.pageBreak", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": name}));
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let fields: Vec<(String, String)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref() == Some("TOC1"))
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
wordcraft_doc::para::InlineObject::Field { instr, result, .. } => Some((instr.clone(), result.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(fields.len(), 2, "{fields:?}");
|
||||
for ((instr, result), page) in fields.iter().zip(["2", "3"]) {
|
||||
let mark = instr.strip_prefix("PAGEREF ").and_then(|r| r.strip_suffix(" \\h")).unwrap_or_else(|| panic!("{instr}"));
|
||||
assert!(mark.starts_with("_Toc") && s.doc.bookmarks().iter().any(|(n, _)| n == mark), "{instr}");
|
||||
assert_eq!(result, page);
|
||||
}
|
||||
let expected = text(&s);
|
||||
assert!(expected.contains("Alpha\t2") && expected.contains("Bravo\t3"), "{expected}");
|
||||
let bytes = crate::io::save_bytes("toc.docx", &s.doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert_eq!(xml.matches("PAGEREF _Toc").count(), 2, "{xml}");
|
||||
let mut s = Session::new(crate::io::open_bytes("toc.docx", &bytes).unwrap());
|
||||
run(&mut s, "references.updateFields", json!({}));
|
||||
assert_eq!(text(&s), expected);
|
||||
}
|
||||
|
||||
/// Headings numbered through a list carry their numbers, and the level's separator, into the
|
||||
/// TOC entries.
|
||||
#[test]
|
||||
@@ -4594,7 +4998,7 @@ fn word_count_with_an_object_selected_counts_the_document() {
|
||||
fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() {
|
||||
let mut s = s();
|
||||
let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1);
|
||||
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into() });
|
||||
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into(), ..Default::default() });
|
||||
let mut custom = serde_json::Map::new();
|
||||
custom.insert(long.clone(), serde_json::Value::Null);
|
||||
run(&mut s, "file.properties", json!({"custom": custom}));
|
||||
@@ -4631,6 +5035,36 @@ fn custom_properties_case_only_rename_keeps_the_new_spelling() {
|
||||
assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value");
|
||||
}
|
||||
|
||||
/// Linked text objects carry rename metadata explicitly and retain the existing input bounds.
|
||||
#[test]
|
||||
fn custom_properties_linked_values_are_typed_bounded_and_atomic() {
|
||||
let mut s = s();
|
||||
s.doc.set_custom_prop("Status", "draft");
|
||||
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
|
||||
let before = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
let max = crate::cmd::file::MAX_CUSTOM_VALUE;
|
||||
for value in [
|
||||
json!({"value": "changed", "linkTarget": 7}),
|
||||
json!({"value": 7, "linkTarget": "OriginalBookmark"}),
|
||||
json!({"value": "changed", "linkTarget": "OriginalBookmark", "kind": "raw"}),
|
||||
json!({"value": "x".repeat(max + 1), "linkTarget": "OriginalBookmark"}),
|
||||
json!({"value": "changed", "linkTarget": "x".repeat(max + 1)}),
|
||||
] {
|
||||
assert!(s.run("file.properties", &json!({"title": "must roll back", "custom": {"Status": null, "Renamed": value}})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": {"value": "final", "linkTarget": "OriginalBookmark"}}}));
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": "string edit"}}));
|
||||
assert_eq!(s.doc.custom_props[0].link_target.as_deref(), Some("OriginalBookmark"));
|
||||
for value in [json!({"value": "unlinked", "linkTarget": null}), json!({"value": "unlinked"})] {
|
||||
s.doc.custom_props[0].link_target = Some("OriginalBookmark".into());
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": value}}));
|
||||
assert_eq!(s.doc.custom_props[0].link_target, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx
|
||||
/// save and reopen.
|
||||
#[test]
|
||||
@@ -11086,3 +11520,62 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
assert_eq!(grid.header_chr.size, Some(16.0));
|
||||
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
|
||||
}
|
||||
|
||||
/// #3676: a 1-based page parameter becomes a 0-based index without wrapping.
|
||||
/// On 32-bit and wasm the old cast truncated 2^32 to 0 and `0 - 1` panicked.
|
||||
#[test]
|
||||
fn page_index_saturates_instead_of_wrapping_to_page_one() {
|
||||
assert_eq!(crate::p::page_index(0), 0);
|
||||
assert_eq!(crate::p::page_index(1), 0);
|
||||
assert_eq!(crate::p::page_index(2), 1);
|
||||
assert_ne!(crate::p::page_index(1u64 << 32), 0, "2^32 must not wrap to page 1");
|
||||
assert_eq!(crate::p::page_index(u64::MAX), usize::MAX - 1);
|
||||
}
|
||||
|
||||
/// #3676: absolute page numbers that cannot be an index are rejected, not silently
|
||||
/// turned into page 1. `edit.goto` clamps to the last page, as it does past the end.
|
||||
#[test]
|
||||
fn hostile_absolute_pages_never_land_on_page_one() {
|
||||
let mut s = s();
|
||||
for i in 0..5 {
|
||||
if i > 0 {
|
||||
run(&mut s, "insert.pageBreak", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)}));
|
||||
}
|
||||
let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1;
|
||||
run(&mut s, "edit.goto", json!({"page": 1u64 << 32}));
|
||||
assert_eq!(page(&mut s), 5, "an out-of-range page lands on the last page");
|
||||
run(&mut s, "edit.goto", json!({"page": u64::MAX}));
|
||||
assert_eq!(page(&mut s), 5, "u64::MAX lands on the last page");
|
||||
run(&mut s, "edit.goto", json!({"page": 0}));
|
||||
assert_eq!(page(&mut s), 1, "page 0 is the first page");
|
||||
}
|
||||
|
||||
/// #3676: `caret.set` takes a 0-based page; an oversized page is rejected instead of
|
||||
/// truncating to page 0 on 32-bit and wasm.
|
||||
#[test]
|
||||
fn caret_set_rejects_a_page_number_that_does_not_fit() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
let r = s.run("caret.set", &json!({"page": 1u64 << 32, "x": 100.0, "y": 100.0}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}");
|
||||
}
|
||||
|
||||
/// #3676: `file.exportPng` rejects a page number that cannot be an index instead of
|
||||
/// exporting page 1 (the 32-bit truncation) and writing it to the requested path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_png_rejects_a_page_number_that_does_not_fit() {
|
||||
let dir = scratch_dir("export-png-hostile-page");
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
let target = dir.join("page.png");
|
||||
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": 1u64 << 32}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page 2^32: {r:?}");
|
||||
assert!(!target.exists(), "no file is written for an invalid page");
|
||||
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy(), "page": u64::MAX}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Params(_))), "page u64::MAX: {r:?}");
|
||||
assert!(!target.exists(), "no file is written for an invalid page");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Relative font sizes keep each selected run's size (#4903).
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{Document, Pos};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn mixed() -> Session {
|
||||
let mut doc = Document::from_text("Large heading\nSmall detail");
|
||||
for (block, size) in [(0, 20.0), (1, 10.0)] {
|
||||
let end = doc.para_at(&Pos::body(block, 0)).unwrap().len();
|
||||
doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| {
|
||||
c.size = Some(size);
|
||||
c.size_cs = Some(size);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let mut s = Session::new(doc);
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn sizes(s: &Session) -> Vec<f32> {
|
||||
(0..2)
|
||||
.map(|block| {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_preserve_mixed_runs_and_one_undo() {
|
||||
for (command, expected) in [
|
||||
("format.growFont", [22.0, 10.5]),
|
||||
("format.shrinkFont", [18.0, 9.0]),
|
||||
("format.growFont1", [21.0, 11.0]),
|
||||
("format.shrinkFont1", [19.0, 9.0]),
|
||||
] {
|
||||
let mut s = mixed();
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
let depth = s.undo_depth();
|
||||
s.run(command, &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), expected, "{command}");
|
||||
assert_eq!(s.sel, selection);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = s.doc.clone();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_resolve_styles_and_preserve_other_properties() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
|
||||
let mut s = mixed();
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "Large".into(),
|
||||
name: "Large".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "Small".into(),
|
||||
name: "Small".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: CharProps { size: Some(10.0), italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
for block in 0..2 {
|
||||
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
|
||||
p.props.style = Some(if block == 0 { "Large" } else { "Normal" }.into());
|
||||
for c in std::iter::once(&mut p.mark).chain(p.runs.iter_mut().map(|r| &mut r.props)) {
|
||||
c.size = None;
|
||||
c.size_cs = None;
|
||||
if block == 1 {
|
||||
c.style = Some("Small".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
s.run("format.growFont", &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), [22.0, 10.5]);
|
||||
for (block, size) in [(0, 22.0), (1, 10.5)] {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
assert_eq!(p.mark.size, Some(size));
|
||||
for (_, c) in p.run_ranges() {
|
||||
assert_eq!((c.size, c.size_cs), (Some(size), Some(size)), "both scripts still get the explicit override");
|
||||
let resolved = s.doc.styles.resolve_char(p.props.style.as_deref(), c);
|
||||
assert!(if block == 0 { resolved.bold } else { resolved.italic });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_change_only_the_selected_unicode_pieces() {
|
||||
let mut s = Session::new(Document::from_text("éA中Bz"));
|
||||
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 3), &|c| c.size = Some(20.0)).unwrap();
|
||||
s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 6), &|c| c.size = Some(10.0)).unwrap();
|
||||
let before = s.doc.clone();
|
||||
s.run("select.range", &json!({"anchor": Pos::body(0, 2), "focus": Pos::body(0, 6)})).unwrap();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let original = before.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "éA中Bz");
|
||||
assert_eq!(p.props_of_char(2).size, Some(21.0));
|
||||
assert_eq!(p.props_of_char(3).size, Some(11.0));
|
||||
assert_eq!(p.props_of_char(0), original.props_of_char(0));
|
||||
assert_eq!(p.props_of_char(6), original.props_of_char(6));
|
||||
assert_eq!(p.mark, original.mark, "a partial selection leaves the paragraph mark alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_the_caret_and_word_targets() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("format.size", &json!({"size": 10})).unwrap();
|
||||
for size in [11.0, 12.0] {
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert_eq!(s.pending.as_ref().unwrap().size, Some(size));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().mark.size, Some(size));
|
||||
}
|
||||
s.run("text.insert", &json!({"text": "abcd ef"})).unwrap();
|
||||
s.doc.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.size = Some(20.0)).unwrap();
|
||||
s.doc.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| c.size = Some(10.0)).unwrap();
|
||||
s.run("caret.set", &json!({"pos": Pos::body(0, 2)})).unwrap();
|
||||
let selection = s.sel.clone();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert_eq!(s.sel, selection);
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!((p.props_of_char(0).size, p.props_of_char(2).size, p.props_of_char(5).size), (Some(21.0), Some(11.0), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_column_cell_and_text_box_targets() {
|
||||
use wordcraft_doc::{Path, StoryRef};
|
||||
|
||||
let mut s = super::column_doc();
|
||||
super::block(&mut s);
|
||||
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
|
||||
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, 6), &|c| c.size = Some(size)).unwrap();
|
||||
}
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert!(s.column_segments().is_some());
|
||||
for (block, size) in [(0, 20.0), (1, 10.0), (2, 14.0)] {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
assert_eq!((p.props_of_char(1).size, p.props_of_char(2).size, p.props_of_char(4).size), (Some(size), Some(size + 1.0), Some(size)));
|
||||
assert_eq!(p.mark.size, Some(size));
|
||||
}
|
||||
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap();
|
||||
let paths = [Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 1, 0])];
|
||||
for (path, size) in paths.iter().zip([20.0, 10.0]) {
|
||||
s.doc.para_mut(StoryRef::Body, path).unwrap().mark.size = Some(size);
|
||||
}
|
||||
s.run("table.selectTable", &json!({})).unwrap();
|
||||
let _ = s.layout();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
for (path, size) in paths.iter().zip([21.0, 11.0]) {
|
||||
assert_eq!(s.doc.para(StoryRef::Body, path).unwrap().mark.size, Some(size), "empty cells retain distinct sizes");
|
||||
}
|
||||
|
||||
let mut s = Session::new(Document::from_text("Body"));
|
||||
let id = s.run("insert.textBox", &json!({"text": "AB"})).unwrap()["story"].as_u64().unwrap() as u32;
|
||||
let story = StoryRef::Part(id);
|
||||
let at = |off| Pos { story, path: Path::top(0), off };
|
||||
for (a, b, size) in [(0, 1, 20.0), (1, 2, 10.0)] {
|
||||
s.doc.format_range(&at(a), &at(b), &|c| c.size = Some(size)).unwrap();
|
||||
}
|
||||
s.run("select.range", &json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, "\u{fffc}".len())})).unwrap();
|
||||
let before = s.doc.para_at(&Pos::body(0, 0)).unwrap().clone();
|
||||
s.run("format.growFont1", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc.para_at(&Pos::body(0, 0)).unwrap(), before);
|
||||
let p = s.doc.para(story, &Path::top(0)).unwrap();
|
||||
assert_eq!((p.props_of_char(0).size, p.props_of_char(1).size), (Some(21.0), Some(11.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_bounds_protection_and_saved_sizes() {
|
||||
for (command, values, expected) in [
|
||||
("format.growFont", [72.0, 1638.0], [80.0, 1638.0]),
|
||||
("format.shrinkFont", [1.0, 8.0], [1.0, 1.0]),
|
||||
("format.growFont1", [1.0, 1638.0], [2.0, 1638.0]),
|
||||
("format.shrinkFont1", [1.0, 1638.0], [1.0, 1637.0]),
|
||||
] {
|
||||
let mut s = mixed();
|
||||
for (block, size) in values.into_iter().enumerate() {
|
||||
let end = s.doc.para_at(&Pos::body(block, 0)).unwrap().len();
|
||||
s.doc.format_range(&Pos::body(block, 0), &Pos::body(block, end), &|c| c.size = Some(size)).unwrap();
|
||||
}
|
||||
s.run(command, &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), expected, "{command}");
|
||||
let bytes = crate::io::save_bytes("sizes.docx", &s.doc).unwrap();
|
||||
let reopened = Session::new(crate::io::open_bytes("sizes.docx", &bytes).unwrap());
|
||||
assert_eq!(sizes(&reopened), expected, "saved {command}");
|
||||
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.run(command, &json!({})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_font_sizes_keep_paragraph_mark_link_filtering() {
|
||||
let mut s = mixed();
|
||||
for (block, style) in [(0, "Hyperlink"), (1, "CustomLink")] {
|
||||
let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(block)).unwrap();
|
||||
p.mark.link = Some("https://example.test/mark".into());
|
||||
p.mark.style = Some(style.into());
|
||||
p.runs[0].props.link = Some("https://example.test/text".into());
|
||||
p.runs[0].props.style = Some("Hyperlink".into());
|
||||
}
|
||||
s.run("format.growFont", &json!({})).unwrap();
|
||||
assert_eq!(sizes(&s), [22.0, 10.5]);
|
||||
for block in 0..2 {
|
||||
let p = s.doc.para_at(&Pos::body(block, 0)).unwrap();
|
||||
assert_eq!(p.mark.link, None);
|
||||
assert_eq!(p.mark.style.as_deref(), if block == 0 { None } else { Some("CustomLink") });
|
||||
assert!(p.run_ranges().all(|(_, c)| c.link.as_deref() == Some("https://example.test/text") && c.style.as_deref() == Some("Hyperlink")));
|
||||
}
|
||||
}
|
||||
@@ -1414,6 +1414,78 @@ fn indic_and_khmer_backspace_removes_one_character() {
|
||||
assert_eq!(paras(&s)[0].0, "a");
|
||||
}
|
||||
|
||||
/// Arabic vowel and Quranic marks are individually editable without retyping their base (#4757).
|
||||
#[test]
|
||||
fn arabic_backspace_removes_one_mark_and_keeps_the_base() {
|
||||
for mark in [
|
||||
'\u{0610}',
|
||||
'\u{064B}',
|
||||
'\u{064C}',
|
||||
'\u{064D}',
|
||||
'\u{064E}',
|
||||
'\u{064F}',
|
||||
'\u{0650}',
|
||||
'\u{0651}',
|
||||
'\u{0652}',
|
||||
'\u{065F}',
|
||||
'\u{0670}',
|
||||
'\u{06D6}',
|
||||
'\u{06DF}',
|
||||
'\u{06E7}',
|
||||
'\u{06EA}',
|
||||
'\u{0897}',
|
||||
'\u{089F}',
|
||||
'\u{08CA}',
|
||||
'\u{08E1}',
|
||||
'\u{08E3}',
|
||||
'\u{08FF}',
|
||||
'\u{10EFA}',
|
||||
'\u{10EFF}',
|
||||
] {
|
||||
let original = format!("Λ ب{mark}");
|
||||
let mut s = typed(&original);
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(paras(&s)[0].0, "Λ ب", "mark U+{:04X}", u32::from(mark));
|
||||
assert_eq!(s.sel.focus.off, "Λ ب".len());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(paras(&s)[0].0, original);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
typ(&mut s, "\u{0650}");
|
||||
assert_eq!(paras(&s)[0].0, "Λ ب\u{0650}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_backspace_uses_stored_mark_order_but_delete_keeps_the_cluster() {
|
||||
for marks in ["\u{0651}\u{064E}", "\u{064E}\u{0651}"] {
|
||||
let mut s = typed(&format!("ب{marks}"));
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(paras(&s)[0].0, format!("ب{}", marks.chars().next().unwrap()));
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(paras(&s)[0].0, "ب");
|
||||
typ(&mut s, "\u{8}");
|
||||
assert!(paras(&s)[0].0.is_empty());
|
||||
let original = format!("ب{marks}ت");
|
||||
let mut s = typed(&original);
|
||||
// Arabic arrows move visually. They still visit only whole-cluster boundaries.
|
||||
let boundaries = [0, original.len() - "ت".len(), original.len()];
|
||||
let mut visited = vec![s.sel.focus.off];
|
||||
for command in ["caret.right", "caret.right", "caret.left", "caret.left"] {
|
||||
run(&mut s, command, json!({}));
|
||||
assert!(boundaries.contains(&s.sel.focus.off));
|
||||
visited.push(s.sel.focus.off);
|
||||
}
|
||||
assert!(boundaries.iter().all(|off| visited.contains(off)));
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(paras(&s)[0].0, "ت");
|
||||
}
|
||||
for cluster in ["e\u{0301}", "👩💻", "👍🏽", "🇵🇱", "ב\u{05B7}"] {
|
||||
let s = typed(&format!("a{cluster}\u{8}"));
|
||||
assert_eq!(paras(&s)[0].0, "a", "unchanged grapheme {cluster}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Press a shortcut the way the keyboard does: run the command bound to it.
|
||||
fn press(s: &mut Session, combo: &str) -> Result<serde_json::Value, crate::CmdError> {
|
||||
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));
|
||||
@@ -3124,6 +3196,37 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
|
||||
/// #4086: a caret inside a word highlights what is typed next, not the word; Bold still
|
||||
/// formats the word.
|
||||
#[test]
|
||||
fn highlight_at_a_caret_leaves_the_word_alone() {
|
||||
let mut s = typed("Alpha Beta");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "format.highlight", json!({"color":"yellow"}));
|
||||
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight.is_none()));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
|
||||
run(&mut s, "text.insert", json!({"text":"X"}));
|
||||
run(&mut s, "select.text", json!({"text":"X"}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("FFFF00"));
|
||||
run(&mut s, "select.text", json!({"text":"Beta"}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
// The toggle reads the pending colour, not the word's: a second press turns it off.
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Yellow));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
|
||||
// Bold after Highlight formats the word and keeps both for what is typed next.
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text":"Y"}));
|
||||
run(&mut s, "select.text", json!({"text":"Y"}));
|
||||
let state = s.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!((state["highlight"].clone(), state["bold"].clone()), (json!("FFFF00"), json!(true)));
|
||||
}
|
||||
|
||||
/// A differently coloured final paragraph must remain visible to the shortcut beyond
|
||||
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -3386,3 +3489,51 @@ fn double_space_adds_a_period_when_asked() {
|
||||
typ(&mut t, "Hello world");
|
||||
assert_eq!(text(&t), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() {
|
||||
let mut s = typed("Line one\nLine two");
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "Box"}));
|
||||
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
|
||||
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
|
||||
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
|
||||
let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len());
|
||||
assert_eq!(objects(&s), Some(1));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
|
||||
assert_eq!(objects(&s), None);
|
||||
assert_eq!(
|
||||
s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())),
|
||||
Some(("Line oneLine two".to_string(), 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() {
|
||||
let mut s = typed("Line one\nLine two");
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "Box"}));
|
||||
let (a, f) = (Pos::body(1, 0), Pos::body(1, 3));
|
||||
run(&mut s, "select.range", json!({"anchor": a, "focus": f}));
|
||||
run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"}));
|
||||
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap();
|
||||
s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap();
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1);
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_cannot_join_into_locked_content_control() {
|
||||
let mut s = typed("Above\nInside");
|
||||
run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}}));
|
||||
run(&mut s, "developer.richText", json!({"tag": "locked"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.run("text.backspace", &json!({})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
@@ -76,6 +76,27 @@ fn moving_a_joined_shape_reroutes_its_connector() {
|
||||
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
|
||||
}
|
||||
|
||||
/// A target on another page, or a missing target, leaves the connector's end free.
|
||||
#[test]
|
||||
fn rerouting_keeps_off_page_and_missing_targets_at_their_free_ends() {
|
||||
let mut s = joined();
|
||||
let mut connector = s.doc.para_at(&Pos::body(0, 11)).unwrap().object_at(11).unwrap().clone();
|
||||
let InlineObject::Shape { extra, .. } = &mut connector else { panic!("connector") };
|
||||
extra.end = Some(ConnEnd { id: 404, site: 2 });
|
||||
let mut p = Paragraph::new();
|
||||
p.props.page_break_before = Some(true);
|
||||
p.insert_object(0, connector.clone(), &Default::default()).unwrap();
|
||||
s.doc.body.push(para_block(p));
|
||||
s.touch();
|
||||
select(&mut s, 5);
|
||||
run(&mut s, "arrange.nudge", json!({"dx": 10, "dy": 30}));
|
||||
assert_eq!(s.layout().pages.len(), 2);
|
||||
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
|
||||
assert_eq!(connector_ends(&mut s), ((182.0, 248.0), (300.0, 218.0)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().object_at(0), Some(&connector));
|
||||
}
|
||||
|
||||
/// A command that changes nothing but reroutes a stale connector is still an edit: the
|
||||
/// reroute must not be left without an undo step (#23).
|
||||
#[test]
|
||||
|
||||
@@ -161,4 +161,4 @@ impl FontDb {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod sysfonts;
|
||||
pub(crate) mod sysfonts;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -12,7 +12,7 @@ fn renamed(file: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family` (13 characters, as long as the original name).
|
||||
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
pub(crate) fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
@@ -27,7 +27,7 @@ fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// The fonts in `faces` as one collection (TTC) file.
|
||||
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
|
||||
pub(crate) fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = b"ttcf".to_vec();
|
||||
out.extend_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||
out.extend_from_slice(&(faces.len() as u32).to_be_bytes());
|
||||
|
||||
@@ -24,7 +24,7 @@ pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
|
||||
+141
-22
@@ -70,6 +70,31 @@ const MAX_NEEDED: usize = 4096;
|
||||
/// as blank space.
|
||||
pub(crate) const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Region {
|
||||
Hans,
|
||||
Hant,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn from_hans(hans: bool) -> Region {
|
||||
if hans { Region::Hans } else { Region::Other }
|
||||
}
|
||||
|
||||
fn rank(self, family: &str) -> u8 {
|
||||
match self {
|
||||
Region::Hans => u8::from(family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei")),
|
||||
Region::Hant => {
|
||||
let taiwan =
|
||||
[" TC", " TW"].iter().any(|s| family.ends_with(s)) || ["JhengHei", "CNS", "LiHei", "MingLiU"].iter().any(|s| family.contains(s));
|
||||
if taiwan { 2 } else { u8::from(family.ends_with(" HK")) }
|
||||
}
|
||||
Region::Other => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A character only a font for the language has: a simplified-only hanzi, kana, or Hangul.
|
||||
fn probe(hans: bool, korean: bool) -> char {
|
||||
if korean {
|
||||
@@ -139,6 +164,46 @@ fn candidates(hans: bool, korean: bool) -> Vec<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn hant_candidates() -> &'static [&'static str] {
|
||||
if cfg!(windows) {
|
||||
&["msjh.ttc", "msjh.ttf", "mingliu.ttc"]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[
|
||||
"PingFang.ttc",
|
||||
"Hiragino Sans CNS.ttc",
|
||||
"LiHeiPro.ttf",
|
||||
"/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc",
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
"noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
||||
"google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"noto-cjk/NotoSansCJKtc-Regular.otf",
|
||||
"opentype/noto/NotoSansCJKtc-Regular.otf",
|
||||
"google-noto-cjk/NotoSansCJKtc-Regular.otf",
|
||||
"adobe-source-han-sans/SourceHanSansTW-Regular.otf",
|
||||
"adobe-source-han-sans/SourceHanSansTC-Regular.otf",
|
||||
"opentype/adobe-source-han-sans/SourceHanSansTW-Regular.otf",
|
||||
"NotoSansCJK-Regular.ttc",
|
||||
"NotoSansCJKtc-Regular.otf",
|
||||
"SourceHanSansTW-Regular.otf",
|
||||
"SourceHanSansTC-Regular.otf",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_hant_ui_font(needed: &[char]) -> Option<SystemUiFont> {
|
||||
let probe = ['體'];
|
||||
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
|
||||
hant_font_in(&with_asset_data(crate::system_font_dirs()), needed)
|
||||
}
|
||||
|
||||
fn hant_font_in(dirs: &[std::path::PathBuf], needed: &[char]) -> Option<SystemUiFont> {
|
||||
find_ui_font(dirs, hant_candidates(), needed, Region::Hant).filter(|f| Region::Hant.rank(&f.family) > 0)
|
||||
}
|
||||
|
||||
/// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed`
|
||||
/// (the characters the embedded faces lack; empty: a character typical of the language), if one
|
||||
/// is found. Never panics on damaged files.
|
||||
@@ -150,7 +215,7 @@ pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||||
pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Option<SystemUiFont> {
|
||||
let probe = [probe(hans, korean)];
|
||||
let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) };
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, hans)
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans))
|
||||
}
|
||||
|
||||
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
|
||||
@@ -175,9 +240,9 @@ pub(crate) fn with_asset_data(dirs: Vec<std::path::PathBuf>) -> Vec<std::path::P
|
||||
/// The font file among `names` (tried in order, each in every folder of `dirs`) whose best face
|
||||
/// maps all of `needed`, or failing that the one mapping the most of them (the earliest on a
|
||||
/// tie). A file that maps none of them is never chosen.
|
||||
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], hans: bool) -> Option<SystemUiFont> {
|
||||
pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], region: Region) -> Option<SystemUiFont> {
|
||||
let mut tried = std::collections::HashSet::new();
|
||||
let mut best: Option<(usize, SystemUiFont)> = None;
|
||||
let mut best: Option<((u8, usize), SystemUiFont)> = None;
|
||||
for path in names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))) {
|
||||
if !tried.insert(path.clone()) {
|
||||
continue;
|
||||
@@ -187,13 +252,15 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed:
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let Some((index, family, covered)) = pick_face(&bytes, needed, hans) else { continue };
|
||||
if best.as_ref().is_some_and(|(b, _)| *b >= covered) {
|
||||
let Some((index, family, covered)) = pick_face(&bytes, needed, region) else { continue };
|
||||
let fit = if region == Region::Hant { region.rank(&family) } else { 0 };
|
||||
if best.as_ref().is_some_and(|(b, _)| *b >= (fit, covered)) {
|
||||
continue;
|
||||
}
|
||||
log::debug!("interface CJK fallback: {family} ({}), {covered} of {} characters", path.display(), needed.len());
|
||||
best = Some((covered, SystemUiFont { family, bytes, index }));
|
||||
if covered >= needed.len() {
|
||||
let done = covered >= needed.len() && (region != Region::Hant || fit == 2);
|
||||
best = Some(((fit, covered), SystemUiFont { family, bytes, index }));
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -205,7 +272,7 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed:
|
||||
/// family (`… SC`, `… GB`, as in a pan-CJK collection). Faces with none of the
|
||||
/// [`OUTLINE_TABLES`] are skipped. `None` if the header isn't a font's or no face with outlines
|
||||
/// maps any of `needed`.
|
||||
pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32, String, usize)> {
|
||||
pub(crate) fn pick_face(data: &[u8], needed: &[char], region: Region) -> Option<(u32, String, usize)> {
|
||||
let magic = data.get(..4)?;
|
||||
if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) {
|
||||
return None;
|
||||
@@ -214,7 +281,7 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32
|
||||
FileRef::Font(_) => 1,
|
||||
FileRef::Collection(c) => c.len().min(MAX_FACES),
|
||||
};
|
||||
let mut best: Option<((usize, bool, bool), u32, String)> = None;
|
||||
let mut best: Option<((u8, usize, bool, u8), u32, String)> = None;
|
||||
for i in 0..count {
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
if !OUTLINE_TABLES.iter().any(|t| f.table_data(skrifa::Tag::new(t)).is_some()) {
|
||||
@@ -226,13 +293,14 @@ pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32
|
||||
continue;
|
||||
}
|
||||
let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue };
|
||||
let regional = !hans || family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei");
|
||||
let score = (covered, style.eq_ignore_ascii_case("Regular"), regional);
|
||||
let rank = region.rank(&family);
|
||||
let first = if region == Region::Hant { rank } else { 0 };
|
||||
let score = (first, covered, style.eq_ignore_ascii_case("Regular"), rank);
|
||||
if best.as_ref().is_none_or(|(s, ..)| score > *s) {
|
||||
best = Some((score, i, family));
|
||||
}
|
||||
}
|
||||
best.map(|((covered, ..), i, family)| (i, family, covered))
|
||||
best.map(|((_, covered, ..), i, family)| (i, family, covered))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -255,6 +323,57 @@ mod tests {
|
||||
assert_eq!(probe(false, true), '한');
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn traditional_chinese_interface_picks_the_taiwan_face() {
|
||||
use crate::fontdb::tests::sysfonts::{collection, renamed_to};
|
||||
assert_eq!(
|
||||
hant_candidates().first(),
|
||||
Some(&if cfg!(windows) {
|
||||
"msjh.ttc"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"PingFang.ttc"
|
||||
} else {
|
||||
"noto-cjk/NotoSansCJK-Regular.ttc"
|
||||
})
|
||||
);
|
||||
let face = |family| renamed_to("SourceSans3-Regular.ttf", family);
|
||||
let pan = collection(&[face("Probe Sans JP"), face("Probe Sans KR"), face("Probe Sans SC"), face("Probe Sans TC"), face("Probe Sans HK")]);
|
||||
assert_eq!(pick_face(&pan, &['A'], Region::Hant), Some((3, "Probe Sans TC".to_string(), 1)));
|
||||
assert_eq!(pick_face(&pan, &['A'], Region::Hans), Some((2, "Probe Sans SC".to_string(), 1)));
|
||||
assert_eq!(pick_face(&pan, &['A'], Region::Other), Some((0, "Probe Sans JP".to_string(), 1)));
|
||||
let hk = collection(&[face("Probe Sans SC"), face("Probe Sans HK")]);
|
||||
assert_eq!(pick_face(&hk, &['A'], Region::Hant).map(|f| f.0), Some(1));
|
||||
assert_eq!(Region::Hant.rank("Microsoft JhengHei"), 2);
|
||||
assert_eq!(Region::Hant.rank("Microsoft YaHei"), 0);
|
||||
let dir = std::env::temp_dir().join(format!("wc-uifallback-hant-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let names = ["missing.ttc", "damaged.ttc", "sc.ttc", "pan.ttc"];
|
||||
std::fs::write(dir.join("damaged.ttc"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||||
std::fs::write(dir.join("sc.ttc"), collection(&[face("Probe Sans SC"), face("Probe Sans JP")])).unwrap();
|
||||
std::fs::write(dir.join("pan.ttc"), &pan).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hant).unwrap();
|
||||
assert_eq!((found.family.as_str(), found.index), ("Probe Sans TC", 3));
|
||||
assert_eq!(find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap().family, "Probe Sans SC");
|
||||
let only_sc = dir.join("only-sc");
|
||||
std::fs::create_dir_all(only_sc.join("noto-cjk")).unwrap();
|
||||
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), collection(&[face("Probe Sans SC")])).unwrap();
|
||||
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).is_none());
|
||||
std::fs::write(only_sc.join("noto-cjk/NotoSansCJK-Regular.ttc"), &pan).unwrap();
|
||||
if !cfg!(windows) && !cfg!(target_os = "macos") {
|
||||
assert_eq!(hant_font_in(std::slice::from_ref(&only_sc), &['A']).map(|f| f.index), Some(3));
|
||||
}
|
||||
assert!(hant_font_in(std::slice::from_ref(&only_sc), &['體']).is_none());
|
||||
let user = dir.join("user-fonts");
|
||||
std::fs::create_dir_all(&user).unwrap();
|
||||
std::fs::write(user.join("NotoSansCJKtc-Regular.otf"), face("Probe Sans TC")).unwrap();
|
||||
if !cfg!(windows) && !cfg!(target_os = "macos") {
|
||||
assert_eq!(hant_font_in(std::slice::from_ref(&user), &['A']).map(|f| f.family), Some("Probe Sans TC".to_string()));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() {
|
||||
// A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486).
|
||||
@@ -289,21 +408,21 @@ mod tests {
|
||||
std::fs::write(dir.join("msyh.ttc"), b"<html>not a font</html>").unwrap();
|
||||
std::fs::write(dir.join("sub/good.ttf"), sans).unwrap();
|
||||
let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"];
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], true).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], Region::Hans).unwrap();
|
||||
assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len()));
|
||||
// No candidate maps a CJK character: nothing is chosen.
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], true).is_none());
|
||||
assert!(pick_face(b"OTT", &['A'], false).is_none());
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], Region::Hans).is_none());
|
||||
assert!(pick_face(b"OTT", &['A'], Region::Other).is_none());
|
||||
// macOS 12 keeps PingFang in `AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData`.
|
||||
std::fs::write(assets.join("0a1b.asset/AssetData/PingFang.ttc"), sans).unwrap();
|
||||
std::fs::write(assets.join("not-an-asset/AssetData/Hidden.ttc"), sans).unwrap();
|
||||
let dirs = with_asset_data(vec![dir.clone(), assets.clone()]);
|
||||
assert_eq!(dirs, [dir.clone(), assets.join("0a1b.asset/AssetData"), assets.clone()]);
|
||||
let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], true).unwrap();
|
||||
let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], Region::Hans).unwrap();
|
||||
assert_eq!(found.family, "Source Sans 3");
|
||||
assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], true).is_none());
|
||||
assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], Region::Hans).is_none());
|
||||
// No file maps every needed character: the one mapping the most is used.
|
||||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], true).unwrap();
|
||||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap();
|
||||
assert_eq!(found.family, "Source Sans 3");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -318,17 +437,17 @@ mod tests {
|
||||
let glyf = (0..tables).map(|r| 12 + r * 16).find(|&at| &hvgl[at..at + 4] == b"glyf").unwrap();
|
||||
hvgl[glyf..glyf + 4].copy_from_slice(b"hvgl");
|
||||
assert!(skrifa::FontRef::new(&hvgl).unwrap().charmap().map('A').is_some());
|
||||
assert!(pick_face(&hvgl, &['A'], true).is_none());
|
||||
assert_eq!(pick_face(sans, &['A'], true), Some((0, "Source Sans 3".to_string(), 1)));
|
||||
assert!(pick_face(&hvgl, &['A'], Region::Hans).is_none());
|
||||
assert_eq!(pick_face(sans, &['A'], Region::Hans), Some((0, "Source Sans 3".to_string(), 1)));
|
||||
// `find_ui_font` uses the next file, and returns `None` when no other file is listed.
|
||||
let dir = std::env::temp_dir().join(format!("wc-uifallback-outlines-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("hvgl.ttf"), &hvgl).unwrap();
|
||||
std::fs::write(dir.join("glyf.ttf"), sans).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], true).unwrap();
|
||||
let found = find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf", "glyf.ttf"], &['A'], Region::Hans).unwrap();
|
||||
assert!(found.bytes == sans);
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], true).is_none());
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &["hvgl.ttf"], &['A'], Region::Hans).is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-metafile = { workspace = true }
|
||||
image = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -66,9 +66,9 @@ fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) {
|
||||
let Some(size) = attr(attrs, "size") else { return };
|
||||
let size = size.trim();
|
||||
let points = if let Some(rest) = size.strip_prefix('+') {
|
||||
rest.parse::<i32>().ok().map(|delta| 3 + delta)
|
||||
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_add(delta))
|
||||
} else if let Some(rest) = size.strip_prefix('-') {
|
||||
rest.parse::<i32>().ok().map(|delta| 3 - delta)
|
||||
rest.parse::<i32>().ok().map(|delta| 3i32.saturating_sub(delta))
|
||||
} else {
|
||||
size.parse::<i32>().ok()
|
||||
};
|
||||
@@ -336,10 +336,11 @@ impl<'r> Builder<'r> {
|
||||
}
|
||||
|
||||
pub(super) fn text(&mut self, t: &str) {
|
||||
if let Some(title) = &mut self.title {
|
||||
title.push_str(t);
|
||||
return;
|
||||
}
|
||||
if self.in_head() {
|
||||
if let Some(title) = &mut self.title {
|
||||
title.push_str(t);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if t.is_empty() {
|
||||
|
||||
@@ -10,7 +10,20 @@ pub fn parse(s: &str) -> Flow {
|
||||
|
||||
/// [`parse`], loading pictures that aren't `data:` URIs through `images`.
|
||||
pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
let mut lx = Lexer { s, pos: 0 };
|
||||
// HTML normalizes input newlines before tokenization, not after decoding character references.
|
||||
let normalized = if s.contains('\r') {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chunks = s.split('\r');
|
||||
out.push_str(chunks.next().unwrap_or_default());
|
||||
for chunk in chunks {
|
||||
out.push('\n');
|
||||
out.push_str(chunk.strip_prefix('\n').unwrap_or(chunk));
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(s)
|
||||
};
|
||||
let mut lx = Lexer { s: &normalized, pos: 0 };
|
||||
let mut b = Builder::new(images);
|
||||
let mut guard = 0usize;
|
||||
while let Some(t) = lx.next() {
|
||||
|
||||
@@ -68,8 +68,14 @@ impl Lexer<'_> {
|
||||
}
|
||||
let b = rest.as_bytes();
|
||||
if rest.starts_with("<!--") {
|
||||
self.pos += 4;
|
||||
self.skip_past("-->");
|
||||
if rest.starts_with("<!-->") {
|
||||
self.pos += 5;
|
||||
} else if rest.starts_with("<!--->") {
|
||||
self.pos += 6;
|
||||
} else {
|
||||
self.pos += 4;
|
||||
self.skip_past("-->");
|
||||
}
|
||||
return Some(Tok::Text(String::new()));
|
||||
}
|
||||
if rest.starts_with("<!") || rest.starts_with("<?") {
|
||||
|
||||
@@ -50,6 +50,26 @@ fn borderless_tables_export_without_cell_borders() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_newlines_preserve_preformatted_lines() {
|
||||
for newline in ["\n", "\r\n", "\r"] {
|
||||
let f = parse(&format!("<pre>{newline} Alpha{newline} <b>Beta</b>{newline}{newline} Gamma</pre>"));
|
||||
assert_eq!(texts(&f), ["CodeNone: Alpha", "CodeNone: Beta", "CodeNone:", "CodeNone: Gamma"], "{newline:?}");
|
||||
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
|
||||
assert!(matches!(p.inlines.last(), Some(Inline::Text(t, f)) if t == "Beta" && f.bold));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_newlines_are_normalized_before_character_references() {
|
||||
let f = parse("<p style='white-space:pre'>A B\r\nC\rD</p><p>A\r\nB\rC</p>");
|
||||
assert_eq!(texts(&f), ["NormalNone:A\rB\nC\nD", "NormalNone:A B C"]);
|
||||
let f = parse("<p style='white-space:pre-wrap'>α\r\r\nβ γ</p><a href='https://example.test/a\r\nb\rc d'>link</a>");
|
||||
assert_eq!(texts(&f), ["NormalNone:α\n\nβ\rγ", "NormalNone:link"]);
|
||||
let FBlock::Para(p) = &f.blocks[1] else { panic!() };
|
||||
assert!(matches!(p.inlines.first(), Some(Inline::Text(_, f)) if f.link.as_deref() == Some("https://example.test/a\nb\nc\rd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_preformatted_paragraphs_keep_edge_spaces() {
|
||||
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
|
||||
@@ -474,3 +494,31 @@ fn many_formatted_leading_breaks_trim_quickly() {
|
||||
let texts: Vec<String> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_size_at_the_integer_limits_does_not_overflow() {
|
||||
// `<font size>` offsets were added to 3 unchecked, so the extreme values panicked in debug builds.
|
||||
for size in ["+2147483647", "--2147483648", "-2147483647", "+99999999999"] {
|
||||
let f = parse(&format!("<font size=\"{size}\">x</font>"));
|
||||
assert!(texts(&f).iter().any(|t| t.ends_with(":x")), "size={size}");
|
||||
}
|
||||
// The offsets saturate, then clamp to the 1..=7 scale: 7 is 36 pt, 1 is 7.5 pt.
|
||||
let size = |html: &str| match &parse(html).blocks[0] {
|
||||
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||||
Inline::Text(_, fmt) => fmt.size,
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(size("<font size=\"+2147483647\">x</font>"), Some(36.0));
|
||||
assert_eq!(size("<font size=\"--2147483648\">x</font>"), Some(36.0));
|
||||
assert_eq!(size("<font size=\"-2147483647\">x</font>"), Some(7.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_comments_do_not_swallow_the_rest() {
|
||||
for html in ["<p>a<!-->b</p><p>c</p>", "<p>a<!--->b</p><p>c</p>"] {
|
||||
let doc = crate::html::import(html);
|
||||
assert_eq!(doc.plain_text(Default::default()), "ab\nc", "{html}");
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ impl Writer {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
let s = self.span(t, f);
|
||||
o.push_str(&s);
|
||||
push_apart(&mut o, &s);
|
||||
}
|
||||
Inline::Image(img) => {
|
||||
let alt = if img.alt.trim().is_empty() { "Picture".to_string() } else { format!("Picture: {}", img.alt.trim()) };
|
||||
@@ -244,7 +244,7 @@ impl Writer {
|
||||
// LaTeX keeps no ruby (no package): its base text, in the run's formatting.
|
||||
Inline::Ruby { base, fmt, .. } => {
|
||||
let s = self.span(base, fmt);
|
||||
o.push_str(&s);
|
||||
push_apart(&mut o, &s);
|
||||
}
|
||||
Inline::Equation { linear, display } => {
|
||||
self.uses.math = true;
|
||||
@@ -398,6 +398,18 @@ fn num(v: f32) -> String {
|
||||
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r}") }
|
||||
}
|
||||
|
||||
/// Appends a run, writing `{}` between a `-` or `'` ending `o` and the same character starting
|
||||
/// `s`, so TeX does not join them across runs into a dash or quote.
|
||||
fn push_apart(o: &mut String, s: &str) {
|
||||
if let Some(c) = s.chars().next()
|
||||
&& (c == '-' || c == '\'')
|
||||
&& o.ends_with(c)
|
||||
{
|
||||
o.push_str("{}");
|
||||
}
|
||||
o.push_str(s);
|
||||
}
|
||||
|
||||
/// Text with LaTeX's special characters escaped.
|
||||
pub fn escape_text(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 8);
|
||||
|
||||
@@ -449,3 +449,14 @@ fn nested_multicolumn_wrappers_are_bounded() {
|
||||
assert_eq!((t.rows[0][0].colspan, t.rows[0][0].rowspan), (2, 2));
|
||||
assert_eq!(t.rows[0][0].text(), "Both");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligature_characters_split_across_runs_stay_apart() {
|
||||
let d = crate::html::import(
|
||||
"<p><span style=\"font-family:Arial\">a-</span><span style=\"font-family:Georgia\">-b '</span><span style=\"font-family:Arial\">'x</span></p>",
|
||||
);
|
||||
let tex = export(&d);
|
||||
assert!(tex.contains("a-{}-b '{}'x"), "{tex}");
|
||||
let back = import(tex.as_bytes());
|
||||
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::blocks::thematic_break;
|
||||
use super::inlines::within;
|
||||
use super::inlines::{is_punct, within};
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, Inline, Kind, model};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -166,30 +166,43 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
|
||||
}
|
||||
let want: Vec<Mark> =
|
||||
[(f.bold, Mark::Bold), (f.italic, Mark::Italic), (f.strike, Mark::Strike)].iter().filter(|x| x.0).map(|x| x.1).collect();
|
||||
let body = if f.code {
|
||||
if in_table { code_span(&text.replace('|', "\\|")) } else { code_span(&text) }
|
||||
} else {
|
||||
escape(&text).replace('\n', "\\\n").replace('\t', " ")
|
||||
};
|
||||
// Close until the stack is a prefix of what we want.
|
||||
let mut close = String::new();
|
||||
while let Some(pos) = stack.iter().position(|m| !want.contains(m)) {
|
||||
while stack.len() > pos {
|
||||
if let Some(m) = stack.pop() {
|
||||
out.push_str(marker(m));
|
||||
close.push_str(marker(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut open = String::new();
|
||||
for m in want {
|
||||
if !stack.contains(&m) {
|
||||
out.push_str(marker(m));
|
||||
open.push_str(marker(m));
|
||||
stack.push(m);
|
||||
}
|
||||
}
|
||||
if f.code {
|
||||
if in_table {
|
||||
let t = text.replace('|', "\\|");
|
||||
out.push_str(&code_span(&t));
|
||||
} else {
|
||||
out.push_str(&code_span(&text));
|
||||
}
|
||||
} else {
|
||||
out.push_str(&escape(&text).replace('\n', "\\\n").replace('\t', " "));
|
||||
// CommonMark only reads a closing run after punctuation as closing when whitespace or
|
||||
// punctuation follows it, and an opening run before punctuation as opening when whitespace
|
||||
// or punctuation precedes it. Where a letter sits there instead, or an opening run would
|
||||
// directly follow a closing run (the two would merge into one run), an empty HTML comment (which readers drop) supplies
|
||||
// the punctuation.
|
||||
let prev = out.chars().next_back();
|
||||
let next = body.chars().next();
|
||||
let word = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace() && !is_punct(c));
|
||||
out.push_str(&close);
|
||||
let close_needs = !close.is_empty() && prev.is_some_and(is_punct) && (!open.is_empty() || word(next));
|
||||
let open_needs = !open.is_empty() && next.is_some_and(is_punct) && (!close.is_empty() || word(prev));
|
||||
if close_needs || open_needs {
|
||||
out.push_str(EMPTY_COMMENT);
|
||||
}
|
||||
out.push_str(&open);
|
||||
out.push_str(&body);
|
||||
}
|
||||
close_all(&mut out, &mut stack);
|
||||
if let Some(l) = &link {
|
||||
@@ -229,6 +242,9 @@ fn entity_like(rest: &str) -> bool {
|
||||
!body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// Inline HTML that Markdown readers drop, used to separate emphasis markers from letters.
|
||||
const EMPTY_COMMENT: &str = "<!-- -->";
|
||||
|
||||
fn marker(m: Mark) -> &'static str {
|
||||
match m {
|
||||
Mark::Bold => "**",
|
||||
|
||||
@@ -16,7 +16,7 @@ enum Node {
|
||||
Delim { ch: char, orig: usize, left: usize, open: bool, close: bool, opens: Vec<Emph>, closes: Vec<Emph> },
|
||||
}
|
||||
|
||||
fn is_punct(c: char) -> bool {
|
||||
pub(super) fn is_punct(c: char) -> bool {
|
||||
c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@ struct LinkScan<'a> {
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl LinkScan<'_> {
|
||||
fn parse_at(&mut self, open: usize) -> Option<(String, Arc<str>, usize)> {
|
||||
impl<'a> LinkScan<'a> {
|
||||
fn parse_at(&mut self, open: usize) -> Option<(&'a str, Arc<str>, usize)> {
|
||||
let bytes = self.text.as_bytes();
|
||||
let close = match self.known.get(&open).copied() {
|
||||
Some(found) => found,
|
||||
@@ -136,19 +136,32 @@ impl LinkScan<'_> {
|
||||
let destination = link_dest(self.text, close + 1);
|
||||
self.budget = self.budget.saturating_sub(destination.as_ref().map_or(self.text.len(), |d| d.1).saturating_sub(close));
|
||||
let (destination, end) = destination?;
|
||||
return Some((label_text.to_string(), Arc::from(destination), end));
|
||||
return Some((label_text, Arc::from(destination), end));
|
||||
}
|
||||
// Full and shortcut reference links use definitions collected before inline parsing.
|
||||
if self.refs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (label, end) = match self.text.get(close + 1..).and_then(|rest| rest.strip_prefix('[')) {
|
||||
Some(rest) => {
|
||||
let shut = rest.find(']')?;
|
||||
let ahead = within(rest, self.budget);
|
||||
let shut = ahead.find(']');
|
||||
self.budget = self.budget.saturating_sub(shut.map_or(ahead.len(), |n| n + 1));
|
||||
let shut = shut?;
|
||||
let inner = rest.get(..shut)?;
|
||||
let label = if inner.trim().is_empty() { label_text } else { inner };
|
||||
(label, close + 2 + shut + 1)
|
||||
}
|
||||
None => (label_text, close + 1),
|
||||
};
|
||||
Some((label_text.to_string(), self.refs.get(&ref_label(label))?.clone(), end))
|
||||
// A cached bracket pair still costs a pass over its label. Without charging it,
|
||||
// nested undefined references normalize every suffix again.
|
||||
if label.len() > self.budget {
|
||||
self.budget = 0;
|
||||
return None;
|
||||
}
|
||||
self.budget -= label.len();
|
||||
Some((label_text, self.refs.get(&ref_label(label))?.clone(), end))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,10 +270,10 @@ fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||
|
||||
fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
let mut nodes: Vec<Node> = Vec::new();
|
||||
// Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(`
|
||||
// would otherwise rescan the rest of the text, which is quadratic on hostile input; once the
|
||||
// budget is gone, further brackets are plain text. A scan pays for what it reads; brackets an
|
||||
// earlier scan passed are looked up in `known` instead.
|
||||
// Bytes the link/image scans and reference-label lookups may read in total. Unmatched
|
||||
// brackets and nested undefined references would otherwise rescan the rest of the text,
|
||||
// which is quadratic on hostile input. Once the budget is gone, further brackets are plain
|
||||
// text. A scan pays for what it reads; earlier bracket matches are looked up in `known`.
|
||||
let ticks = Backticks::new(s);
|
||||
let mut links = LinkScan { text: s, refs, ticks: &ticks, known: HashMap::new(), budget: s.len().saturating_mul(8).saturating_add(8192) };
|
||||
let b = s.as_bytes();
|
||||
@@ -324,7 +337,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
match parsed {
|
||||
Some((inner, dest, end)) if img => {
|
||||
flush!();
|
||||
let alt: String = parse_inlines(&inner, &Fmt::default(), depth + 1, refs)
|
||||
let alt: String = parse_inlines(inner, &Fmt::default(), depth + 1, refs)
|
||||
.iter()
|
||||
.filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None })
|
||||
.collect();
|
||||
@@ -343,7 +356,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
if f.link.is_none() {
|
||||
model::dropped(model::Dropped::Link);
|
||||
}
|
||||
for x in parse_inlines(&inner, &f, depth + 1, refs) {
|
||||
for x in parse_inlines(inner, &f, depth + 1, refs) {
|
||||
nodes.push(Node::Inl(x));
|
||||
}
|
||||
i = end;
|
||||
|
||||
@@ -79,6 +79,53 @@ fn reference_links_resolve_and_their_definitions_leave_the_body() {
|
||||
assert_eq!(links(fenced), vec![], "a fenced definition defines nothing");
|
||||
}
|
||||
|
||||
/// Cached bracket pairs must not make undefined shortcut references normalize every nested
|
||||
/// suffix again. One unrelated definition keeps this on the reference-lookup path.
|
||||
#[test]
|
||||
fn nested_undefined_reference_labels_have_bounded_work() {
|
||||
let literal = format!("prefix {}x{} suffix", "[".repeat(128_000), "]".repeat(128_000));
|
||||
let src = format!("{literal}\n\n[other]: https://example.com/other\n");
|
||||
let started = std::time::Instant::now();
|
||||
let doc = import(&src);
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), literal);
|
||||
assert!(elapsed < std::time::Duration::from_secs(10), "nested undefined references took {elapsed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_lookup_keeps_long_unicode_labels_and_nested_text() {
|
||||
// The work guard is not a label-length cap. Collapsed/full/shortcut references keep their
|
||||
// case and whitespace matching, even when lowercasing changes the number of UTF-8 bytes.
|
||||
let label = format!("{}İ END", "Écho ".repeat(1500));
|
||||
let key = label.to_lowercase();
|
||||
let src = format!("[{label}] [{label}][] [full][{label}]\n\n[{key}]: https://example.com/long\n");
|
||||
let flow = parse(&src);
|
||||
let FBlock::Para(p) = &flow.blocks[0] else { panic!("paragraph") };
|
||||
let links: Vec<_> = p
|
||||
.inlines
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Inline::Text(t, f) if f.link.is_some() => Some((t.as_str(), f.link.as_deref())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
links,
|
||||
vec![
|
||||
(label.as_str(), Some("https://example.com/long")),
|
||||
(label.as_str(), Some("https://example.com/long")),
|
||||
("full", Some("https://example.com/long"))
|
||||
]
|
||||
);
|
||||
|
||||
let refs = Refs::from([("known".into(), Arc::from("https://example.com/known"))]);
|
||||
let out = parse_inlines("[outer [known]] tail [inline](https://example.com/inline)", &Fmt::default(), 0, &refs);
|
||||
let text: String = out.iter().filter_map(|x| if let Inline::Text(t, _) = x { Some(t.as_str()) } else { None }).collect();
|
||||
assert_eq!(text, "[outer known] tail inline");
|
||||
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "known" && f.link.as_deref() == Some("https://example.com/known"))));
|
||||
assert!(out.iter().any(|x| matches!(x, Inline::Text(t, f) if t == "inline" && f.link.as_deref() == Some("https://example.com/inline"))));
|
||||
}
|
||||
|
||||
/// A definition has to start a block (CommonMark §4.7), and a fence opens and closes by the
|
||||
/// same rules the block parser uses. Reading a line without that context deleted text: a
|
||||
/// definition-shaped line inside a paragraph, and every such line inside a code block whose
|
||||
@@ -154,6 +201,69 @@ fn export_escapes_round_trip() {
|
||||
assert!(q.list.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_emphasis_next_to_punctuation_round_trips() {
|
||||
let marks: [(&str, fn(&mut Fmt)); 3] = [("bold", |f| f.bold = true), ("italic", |f| f.italic = true), ("strike", |f| f.strike = true)];
|
||||
let cases: [&[(&str, bool)]; 4] = [
|
||||
&[("注意:", true), ("请阅读", false)],
|
||||
&[("a.", true), ("b", false)],
|
||||
&[("a", false), ("!", true), ("b", false)],
|
||||
&[("a", false), ("(x)", true), ("b", false)],
|
||||
];
|
||||
for (name, set) in marks {
|
||||
for runs in cases {
|
||||
let mut p = Para::default();
|
||||
for (t, on) in runs {
|
||||
let mut f = Fmt::default();
|
||||
if *on {
|
||||
set(&mut f);
|
||||
}
|
||||
p.push_text(t, &f);
|
||||
}
|
||||
let md = inlines_md(&p.inlines);
|
||||
let back = parse(&md);
|
||||
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
|
||||
let want: String = runs.iter().map(|r| r.0).collect();
|
||||
assert_eq!(q.text(), want, "{name} exported as {md:?}");
|
||||
let got: Vec<(String, bool)> = q
|
||||
.inlines
|
||||
.iter()
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.bold || f.italic || f.strike)) } else { None })
|
||||
.collect();
|
||||
let expect: Vec<(String, bool)> = runs.iter().map(|(t, on)| (t.to_string(), *on)).collect();
|
||||
assert_eq!(got, expect, "{name} exported as {md:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_adjacent_emphasis_next_to_punctuation_round_trips() {
|
||||
let b = Fmt { bold: true, ..Fmt::default() };
|
||||
let i = Fmt { italic: true, ..Fmt::default() };
|
||||
let s = Fmt { strike: true, ..Fmt::default() };
|
||||
let cases: [&[(&str, &Fmt)]; 6] = [
|
||||
&[("ab", &b), ("!c", &i)],
|
||||
&[("ab", &i), ("!c", &b)],
|
||||
&[("ab", &b), ("!c", &s)],
|
||||
&[("ab", &s), ("(c)", &b)],
|
||||
&[("a.", &b), ("b", &i)],
|
||||
&[("a.", &b), ("!b", &i)],
|
||||
];
|
||||
for runs in cases {
|
||||
let mut p = Para::default();
|
||||
for (t, f) in runs {
|
||||
p.push_text(t, f);
|
||||
}
|
||||
let md = inlines_md(&p.inlines);
|
||||
let back = parse(&md);
|
||||
let FBlock::Para(q) = &back.blocks[0] else { panic!() };
|
||||
let got: Vec<(String, bool, bool, bool)> =
|
||||
q.inlines.iter().filter_map(|x| if let Inline::Text(t, f) = x { Some((t.clone(), f.bold, f.italic, f.strike)) } else { None }).collect();
|
||||
let expect: Vec<(String, bool, bool, bool)> = runs.iter().map(|(t, f)| (t.to_string(), f.bold, f.italic, f.strike)).collect();
|
||||
assert_eq!(got, expect, "exported as {md:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_line_start_preserves_indentation() {
|
||||
assert_eq!(escape_line_start(" indented text".to_string()), " indented text");
|
||||
|
||||
@@ -21,7 +21,7 @@ mod types;
|
||||
|
||||
pub use color::parse_color;
|
||||
pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, is_mono, para_kind};
|
||||
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sniff_image};
|
||||
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
|
||||
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
|
||||
pub use types::{
|
||||
CODE_STYLE, Cell, FBlock, FTable, Flow, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, MONO_FONT, Meta, Para,
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
|
||||
return Some(Rgb(d(0)?, d(1)?, d(2)?));
|
||||
}
|
||||
if h.len() == 8 && h.is_ascii() {
|
||||
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
|
||||
return Some(Rgb(p(0)?, p(2)?, p(4)?));
|
||||
}
|
||||
@@ -119,3 +119,14 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn eight_digit_hex_rejects_signs() {
|
||||
assert_eq!(parse_color("#+1+2+3+4"), None);
|
||||
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,14 @@ pub fn decode_bounded(data: &[u8]) -> Option<image::DynamicImage> {
|
||||
pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<Img> {
|
||||
let data = data.into();
|
||||
let ext = sniff_image(&data)?;
|
||||
let (pw, ph) = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75)).unwrap_or((72.0, 72.0));
|
||||
let natural = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75));
|
||||
Some(sized_img(data, ext, natural, w, h, alt))
|
||||
}
|
||||
|
||||
/// A picture of a known format `ext` whose natural size (points) the caller knows, if at all: a
|
||||
/// metafile, which `image` can't measure. Sized like [`make_img`].
|
||||
pub fn sized_img(data: Arc<Vec<u8>>, ext: &str, natural: Option<(f32, f32)>, w: Option<f32>, h: Option<f32>, alt: &str) -> Img {
|
||||
let (pw, ph) = natural.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((72.0, 72.0));
|
||||
let (pw, ph) = (pw.max(1.0), ph.max(1.0));
|
||||
let (mut w, mut h) = match (w.filter(|v| v.is_finite() && *v > 0.0), h.filter(|v| v.is_finite() && *v > 0.0)) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
@@ -45,7 +52,7 @@ pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, a
|
||||
h *= 468.0 / w;
|
||||
w = 468.0;
|
||||
}
|
||||
Some(Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() })
|
||||
Img { data, ext: ext.to_string(), w: w.clamp(1.0, 1584.0), h: h.clamp(1.0, 1584.0), alt: alt.to_string() }
|
||||
}
|
||||
|
||||
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
@@ -341,9 +341,19 @@ impl Writer {
|
||||
}
|
||||
|
||||
fn picture(&mut self, img: &model::Img, out: &mut String) {
|
||||
// A metafile's `\picw`/`\pich` are its size in 0.01 mm (#4259).
|
||||
let hundredths = ((img.w * 2540.0 / 72.0).round() as i64, (img.h * 2540.0 / 72.0).round() as i64);
|
||||
let (data, blip) = match img.ext.as_str() {
|
||||
"png" => (img.data.to_vec(), "\\pngblip"),
|
||||
"jpeg" | "jpg" => (img.data.to_vec(), "\\jpegblip"),
|
||||
// The bytes decide, as on reading: an EMF starts with its header record, type 1 (a
|
||||
// DOCX may store one under `.wmf`).
|
||||
"emf" | "wmf" if img.data.starts_with(&[1, 0, 0, 0]) => (img.data.to_vec(), "\\emfblip"),
|
||||
// RTF holds a WMF without its 22-byte placeable header.
|
||||
"emf" | "wmf" => {
|
||||
let data = if img.data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) { img.data.get(22..).unwrap_or_default() } else { img.data.as_slice() };
|
||||
(data.to_vec(), "\\wmetafile8")
|
||||
}
|
||||
_ => {
|
||||
// Re-encode other formats as PNG, bounding the decode so a tiny file declaring
|
||||
// huge dimensions can't exhaust memory on export.
|
||||
@@ -355,7 +365,14 @@ impl Writer {
|
||||
(buf, "\\pngblip")
|
||||
}
|
||||
};
|
||||
let (pw, ph) = model::image_px(&data).unwrap_or((1, 1));
|
||||
// A placeable header with nothing after it leaves no picture to write.
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (pw, ph) = match blip {
|
||||
"\\emfblip" | "\\wmetafile8" => hundredths,
|
||||
_ => model::image_px(&data).map(|(a, b)| (i64::from(a), i64::from(b))).unwrap_or((1, 1)),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{{\\pict{blip}\\picw{pw}\\pich{ph}\\picwgoal{}\\pichgoal{}\n",
|
||||
(img.w * 20.0).round() as i64,
|
||||
|
||||
@@ -62,6 +62,10 @@ pub(super) struct Reader {
|
||||
pub(super) pict_h: Option<f32>,
|
||||
pub(super) pict_sx: f32,
|
||||
pub(super) pict_sy: f32,
|
||||
/// `\picw`/`\pich` as written, read for metafiles (0 when absent).
|
||||
pub(super) pict_nat: (i32, i32),
|
||||
/// The mapping mode `N` of `\wmetafileN`.
|
||||
pub(super) pict_mm: i32,
|
||||
pub(super) bkmk: Option<(usize, String)>,
|
||||
/// Authors of tracked changes (`\revtbl`), and the entry being read.
|
||||
rev_authors: Vec<String>,
|
||||
|
||||
@@ -457,6 +457,94 @@ fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #4259: `\wmetafile8` and `\emfblip` pictures are kept as WMF/EMF media (they were dropped),
|
||||
/// sized from `\picwgoal`/`\pichgoal` and `\picscalex`/`\picscaley`, else from `\picw`/`\pich`
|
||||
/// (0.01 mm for a metafile). As Word writes a floating picture: the `\shprslt` copy is read.
|
||||
#[test]
|
||||
fn metafile_pictures_are_imported() -> Result<(), String> {
|
||||
// A plain WMF: the 9-word header (type 1, version 0x300, 12 words, max record 3), then EOF.
|
||||
let wmf: Vec<u8> = [1u16, 9, 0x300, 12, 0, 0, 3, 0, 0, 3, 0, 0].iter().flat_map(|w| w.to_le_bytes()).collect();
|
||||
let hex: String = wmf.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let images = |rtf: String| -> Result<Vec<model::Img>, String> {
|
||||
let f = parse(rtf.as_bytes())?;
|
||||
Ok(f.blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
FBlock::Para(p) => Some(p.inlines.clone()),
|
||||
FBlock::Table(_) => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter_map(|i| match i {
|
||||
Inline::Image(img) => Some(img),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
};
|
||||
let pict = format!("{{\\pict\\wmetafile8\\picw38761\\pich12832\\picwgoal21972\\pichgoal7272\\picscalex45\\picscaley45 {hex}}}");
|
||||
let shape = format!("{{\\shp{{\\*\\shpinst{{\\sp{{\\sn pib}}{{\\sv {pict}}}}}}}{{\\shprslt{pict}}}}}");
|
||||
let got = images(format!("{{\\rtf1\\ansi Before{shape}After\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("wmf", &wmf));
|
||||
// 21972 twips at 45% is 494.4 pt, shrunk to the 468 pt column like every imported picture.
|
||||
assert_eq!(img.w, 468.0);
|
||||
assert!((img.h - 7272.0 / 20.0 * 0.45 * 468.0 / 494.37).abs() < 0.1, "{}", img.h);
|
||||
// No goal size: the natural size, 2540 × 1270 hundredths of a millimetre = 72 × 36 pt; under
|
||||
// MM_TEXT (`\wmetafile1`) the same numbers are pixels.
|
||||
let hex_of = |b: &[u8]| -> String { b.iter().map(|b| format!("{b:02x}")).collect() };
|
||||
let mut emf = vec![0u8; 88];
|
||||
emf[0] = 1;
|
||||
emf[4] = 88;
|
||||
emf[40..44].copy_from_slice(b" EMF");
|
||||
let emf_hex = hex_of(&emf);
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\emfblip\\picw2540\\pich1270 {emf_hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.w, img.h, img.data.as_ref()), ("emf", 72.0, 36.0, &emf));
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile1\\picw96\\pich48 {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.w, img.h), ("wmf", 72.0, 36.0));
|
||||
// `\picscalex`/`\picscaley` scale the natural size when there is no goal size.
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270\\picscalex50\\picscaley200 {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.w, img.h), (36.0, 72.0));
|
||||
// The other mapping modes use their own units: 0.1 mm, 0.01 in, 0.001 in and twips.
|
||||
for (mode, w, h) in [(2, 254, 127), (4, 100, 50), (5, 1000, 500), (6, 1440, 720)] {
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile{mode}\\picw{w}\\pich{h} {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert!((img.w - 72.0).abs() < 0.01 && (img.h - 36.0).abs() < 0.01, "mode {mode}: {} x {}", img.w, img.h);
|
||||
}
|
||||
// Hostile: a zero or negative natural size falls back to the default 72 pt square, and bytes
|
||||
// that are no picture at all are dropped, whatever the control word says.
|
||||
let got = images(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw0\\pich-5 {hex}}}\\par}}"))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.w, img.h), (72.0, 72.0));
|
||||
for blip in ["wmetafile8", "emfblip"] {
|
||||
assert!(images(format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 0123456789abcdef}}\\par}}"))?.is_empty(), "{blip}");
|
||||
}
|
||||
// Saved as RTF and read back: the same picture, the EMF byte for byte, the WMF without the
|
||||
// placeable header RTF has no room for.
|
||||
let placeable: Vec<u8> = [0xD7u8, 0xCD, 0xC6, 0x9A].into_iter().chain([0u8; 18]).chain(wmf.iter().copied()).collect();
|
||||
for (blip, data, want, ext) in [("emfblip", &emf, &emf, "emf"), ("wmetafile8", &wmf, &wmf, "wmf"), ("wmetafile8", &placeable, &wmf, "wmf")] {
|
||||
let rtf = format!("{{\\rtf1\\ansi{{\\pict\\{blip}\\picw2540\\pich1270 {}}}\\par}}", hex_of(data));
|
||||
let got = images(export(&import(rtf.as_bytes())?))?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{blip}: {} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.data.as_ref(), img.w, img.h), (ext, want, 72.0, 36.0), "{blip}");
|
||||
}
|
||||
// An EMF stored under `.wmf` (a DOCX can) is written as the EMF its bytes are.
|
||||
let mut doc = import(format!("{{\\rtf1\\ansi{{\\pict\\wmetafile8\\picw2540\\pich1270 {hex}}}\\par}}").as_bytes())?;
|
||||
let (key, bytes) = doc.media.iter_mut().next().ok_or("no media")?;
|
||||
assert!(key.ends_with(".wmf"), "{key}");
|
||||
*bytes = std::sync::Arc::new(emf.clone());
|
||||
let rtf = export(&doc);
|
||||
assert!(rtf.contains("\\emfblip") && !rtf.contains("\\wmetafile"), "{rtf}");
|
||||
let got = images(rtf)?;
|
||||
let [img] = got.as_slice() else { return Err(format!("{} pictures", got.len())) };
|
||||
assert_eq!((img.ext.as_str(), img.data.as_ref()), ("emf", &emf));
|
||||
// A placeable header with no records after it has nothing to write: no empty picture group.
|
||||
*doc.media.values_mut().next().ok_or("no media")? = std::sync::Arc::new(placeable[..22].to_vec());
|
||||
assert!(!export(&doc).contains("\\pict"), "an empty metafile is not written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_in_info_bookmarks_and_field_instructions() {
|
||||
// #435: `\uN` (with its `\ucN` fallback skipped) decodes in every text destination.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::state::{Dest, MAX_PICT, St};
|
||||
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The encoding of an East Asian multibyte code page (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5),
|
||||
/// which [`Reader::finish_multibyte`] decodes a run with; `None` for the single-byte code pages.
|
||||
@@ -155,7 +156,28 @@ impl Reader {
|
||||
let sy = if self.pict_sy > 0.0 { self.pict_sy / 100.0 } else { 1.0 };
|
||||
let w = self.pict_w.map(|v| v * sx);
|
||||
let h = self.pict_h.map(|v| v * sy);
|
||||
if let Some(img) = make_img(data, w, h, "") {
|
||||
// The control word only says "a metafile": the bytes decide what it is (#4259).
|
||||
let img = if matches!(self.pict_kind, 3 | 4) && wordcraft_metafile::is_metafile(&data) {
|
||||
// An EMF starts with its header record, type 1.
|
||||
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||
// `\picw`/`\pich` are in the units of the `\wmetafileN` mapping mode; 0.01 mm for the
|
||||
// isotropic and anisotropic modes and for EMF.
|
||||
let unit = match (self.pict_kind, self.pict_mm) {
|
||||
(3, 1) => 0.75, // MM_TEXT: pixels
|
||||
(3, 2) => 72.0 / 254.0, // MM_LOMETRIC: 0.1 mm
|
||||
(3, 4) => 0.72, // MM_LOENGLISH: 0.01 in
|
||||
(3, 5) => 0.072, // MM_HIENGLISH: 0.001 in
|
||||
(3, 6) => 0.05, // MM_TWIPS
|
||||
_ => 72.0 / 2540.0, // MM_HIMETRIC, MM_ISOTROPIC, MM_ANISOTROPIC
|
||||
};
|
||||
let pt = |v: i32| (v > 0).then(|| v.min(1_000_000) as f32 * unit);
|
||||
// `\picscalex`/`\picscaley` scale the natural size too, when no goal size is given.
|
||||
let natural = pt(self.pict_nat.0).zip(pt(self.pict_nat.1)).map(|(a, b)| (a * sx, b * sy));
|
||||
Some(model::sized_img(Arc::new(data), ext, natural, w, h, ""))
|
||||
} else {
|
||||
make_img(data, w, h, "")
|
||||
};
|
||||
if let Some(img) = img {
|
||||
self.para.inlines.push(Inline::Image(img));
|
||||
self.para_has = true;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ impl Reader {
|
||||
self.pict_h = None;
|
||||
self.pict_sx = 0.0;
|
||||
self.pict_sy = 0.0;
|
||||
self.pict_nat = (0, 0);
|
||||
self.pict_mm = 0;
|
||||
}
|
||||
Dest::FldInst if self.st.field.is_none() => self.start_field(),
|
||||
// Old character formatting holds the properties that changed (Word) or, after a
|
||||
@@ -453,6 +455,14 @@ impl Reader {
|
||||
match word {
|
||||
"pngblip" => self.pict_kind = 1,
|
||||
"jpegblip" => self.pict_kind = 2,
|
||||
// Windows and Enhanced Metafiles (#4259): drawn by the metafile renderer.
|
||||
"wmetafile" => {
|
||||
self.pict_kind = 3;
|
||||
self.pict_mm = value;
|
||||
}
|
||||
"emfblip" => self.pict_kind = 4,
|
||||
"picw" => self.pict_nat.0 = value,
|
||||
"pich" => self.pict_nat.1 = value,
|
||||
"picwgoal" => self.pict_w = Some(value.clamp(1, 31_680) as f32 / 20.0),
|
||||
"pichgoal" => self.pict_h = Some(value.clamp(1, 31_680) as f32 / 20.0),
|
||||
"picscalex" => self.pict_sx = value.clamp(1, 1000) as f32,
|
||||
|
||||
@@ -147,7 +147,11 @@ pub(super) fn iso_time(v: &str) -> Option<String> {
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let s = s.round() as u64;
|
||||
let mut s = s.round() as u64;
|
||||
m = m.saturating_add(s / 60);
|
||||
s %= 60;
|
||||
h = h.saturating_add(m / 60);
|
||||
m %= 60;
|
||||
return Some(if s == 0 { format!("{h:02}:{m:02}") } else { format!("{h:02}:{m:02}:{s:02}") });
|
||||
}
|
||||
// xs:time: hh:mm[:ss[.sss]][Z|+...]
|
||||
@@ -159,3 +163,16 @@ pub(super) fn iso_time(v: &str) -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::iso_time;
|
||||
|
||||
#[test]
|
||||
fn duration_seconds_round_up_carries() {
|
||||
assert_eq!(iso_time("PT1H59M59.6S").as_deref(), Some("02:00"));
|
||||
assert_eq!(iso_time("PT59M59.5S").as_deref(), Some("01:00"));
|
||||
assert_eq!(iso_time("PT0H0M59.4S").as_deref(), Some("00:00:59"));
|
||||
assert_eq!(iso_time("PT1H2M3S").as_deref(), Some("01:02:03"));
|
||||
}
|
||||
}
|
||||
@@ -1002,3 +1002,12 @@ fn dropped_names_and_links_are_logged_once_per_document() {
|
||||
let (_, warned) = crate::test_log::warnings(|| crate::latex::import(tex.as_bytes()));
|
||||
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_title_outside_head_is_metadata_not_body() {
|
||||
for html in ["<title>T</title><p>b</p>", "<html><title>T</title><body>b</body></html>"] {
|
||||
let d = crate::html::import(html);
|
||||
assert_eq!(para_texts(&d), vec!["b".to_string()], "{html}");
|
||||
assert_eq!(d.core.title, "T", "{html}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::options::DisplayOptions;
|
||||
use super::paint::{Paint, paint_for};
|
||||
use super::{BezPath, Document, Draw, Graphic, GraphicItem, GraphicKind, InlineObject, PathSeg, Rect, Rgb, ShapeKind, Spin, TextAlign};
|
||||
use super::{BezPath, Document, Draw, FaceRef, Graphic, GraphicItem, GraphicKind, InlineObject, PathSeg, Rect, Rgb, ShapeKind, Spin, TextAlign};
|
||||
|
||||
/// The draws of a chart or diagram's items inside `rect` (page coordinates). The items were built
|
||||
/// for the size `g.w` × `g.h` (0: `rect`'s own size), so they are scaled to `rect`; strokes and text
|
||||
@@ -271,51 +271,113 @@ pub fn seg_path(segs: &[PathSeg]) -> BezPath {
|
||||
}
|
||||
|
||||
/// One line of a `GraphicItem::Text` in `r`, shaped in its font (the document's body font when
|
||||
/// unset) and aligned horizontally, centred vertically, at its size times `k`. `None` when there is
|
||||
/// nothing to draw, or the item's rectangle or size isn't a finite number (a degenerate scale:
|
||||
/// `r` itself is always finite, as [`Rect::new`] makes it).
|
||||
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Option<Draw> {
|
||||
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return None };
|
||||
/// unset) and aligned horizontally, centred vertically, at its size times `k`. Characters the font
|
||||
/// lacks (Chinese in a chart drawn in Calibri's substitute, #4720) are drawn in a fallback face, as
|
||||
/// in body text: one draw per run of a face, in reading order. Empty when there is nothing to draw,
|
||||
/// or the item's rectangle or size isn't a finite number (a degenerate scale: `r` itself is always
|
||||
/// finite, as [`Rect::new`] makes it).
|
||||
fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Vec<Draw> {
|
||||
let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return Vec::new() };
|
||||
let size = *size * k;
|
||||
if !rect.iter().chain([&size]).all(|v| v.is_finite()) {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
let family = font.as_deref().filter(|f| !f.is_empty()).unwrap_or(&doc.settings.minor_font);
|
||||
let size = size.clamp(1.0, 400.0);
|
||||
let resolved = wordcraft_fonts::word::resolve(family, *bold, false);
|
||||
let face = resolved.face.get();
|
||||
let shaped = wordcraft_fonts::shape(face, text, &[], |c| c);
|
||||
if shaped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let k = size / face.upem.max(1.0) as f32;
|
||||
let width: f32 = shaped.iter().map(|g| g.x_advance as f32 * k).sum();
|
||||
let (ascent, descent) = wordcraft_fonts::word::line_metrics(face);
|
||||
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * k / 2.0;
|
||||
let primary = resolved.face;
|
||||
let scale = |f: FaceRef| size / f.upem.max(1.0) as f32;
|
||||
let runs: Vec<_> = face_runs(text, primary, *bold)
|
||||
.into_iter()
|
||||
.map(|(face, s)| (face, s, wordcraft_fonts::shape(face.get(), s, &[], |c| c)))
|
||||
.filter(|(_, _, shaped)| !shaped.is_empty())
|
||||
.collect();
|
||||
let width: f32 = runs.iter().map(|(f, _, shaped)| shaped.iter().map(|g| g.x_advance as f32).sum::<f32>() * scale(*f)).sum();
|
||||
// The font's own line metrics centre the line, whichever faces draw it.
|
||||
let (ascent, descent) = wordcraft_fonts::word::line_metrics(primary.get());
|
||||
let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * scale(primary) / 2.0;
|
||||
let mut pen = match align {
|
||||
TextAlign::Left => r.x,
|
||||
TextAlign::Center => r.x + (r.w - width) / 2.0,
|
||||
TextAlign::Right => r.x + r.w - width,
|
||||
};
|
||||
let mut glyphs = Vec::with_capacity(shaped.len());
|
||||
for g in &shaped {
|
||||
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
|
||||
pen += g.x_advance as f32 * k;
|
||||
let mut out = Vec::with_capacity(runs.len());
|
||||
for (face, s, shaped) in runs {
|
||||
let k = scale(face);
|
||||
let mut glyphs = Vec::with_capacity(shaped.len());
|
||||
for g in &shaped {
|
||||
glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k));
|
||||
pen += g.x_advance as f32 * k;
|
||||
}
|
||||
// A fallback face is emboldened when it has no bold of its own, as in body text.
|
||||
let synth_bold = if face.id() == primary.id() { resolved.synth_bold } else { *bold && face.weight < 600.0 };
|
||||
out.push(Draw::Glyphs {
|
||||
face,
|
||||
size,
|
||||
glyphs,
|
||||
color: *color,
|
||||
alpha,
|
||||
synth_bold,
|
||||
synth_italic: false,
|
||||
text: s.to_string(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
x_scale: 1.0,
|
||||
});
|
||||
}
|
||||
Some(Draw::Glyphs {
|
||||
face: resolved.face,
|
||||
size,
|
||||
glyphs,
|
||||
color: *color,
|
||||
alpha,
|
||||
synth_bold: resolved.synth_bold,
|
||||
synth_italic: false,
|
||||
text: text.clone(),
|
||||
link: None,
|
||||
ranges: Vec::new(),
|
||||
text_bg: None,
|
||||
x_scale: 1.0,
|
||||
})
|
||||
out
|
||||
}
|
||||
|
||||
/// `text` split into runs of the face that draws them: `primary` where it has the character, else a
|
||||
/// fallback face. A run the primary lacks takes one face covering all of it when one does (so a
|
||||
/// Chinese label doesn't mix serif and sans glyphs), else each character its own fallback. Spaces
|
||||
/// and combining marks stay in the run before them; a character no face has stays in `primary`.
|
||||
fn face_runs(text: &str, primary: FaceRef, bold: bool) -> Vec<(FaceRef, &str)> {
|
||||
let joins = |c: char| c.is_whitespace() || wordcraft_doc::math_linear::is_combining(c);
|
||||
// (start, end, drawn by `primary`)
|
||||
let mut spans: Vec<(usize, usize, bool)> = Vec::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
let end = i + c.len_utf8();
|
||||
let own = primary.covers(c);
|
||||
match spans.last_mut() {
|
||||
Some(last) if last.2 == own || joins(c) => last.1 = end,
|
||||
_ => spans.push((i, end, own)),
|
||||
}
|
||||
}
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let mut out: Vec<(FaceRef, &str)> = Vec::with_capacity(spans.len());
|
||||
for (start, end, own) in spans {
|
||||
let Some(s) = text.get(start..end) else { continue };
|
||||
if own {
|
||||
out.push((primary, s));
|
||||
continue;
|
||||
}
|
||||
let chars: Vec<char> = s.chars().filter(|c| !joins(*c)).collect();
|
||||
if let Some(f) = db.fallback_for_run(&chars, primary.id(), bold, false) {
|
||||
out.push((FaceRef::of(&f), s));
|
||||
continue;
|
||||
}
|
||||
// No one face has them all: each character in its own fallback, neighbours sharing a face
|
||||
// in one run.
|
||||
let mut run: Option<(FaceRef, usize)> = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
let face =
|
||||
if joins(c) { None } else { Some(db.fallback_styled(c, primary.id(), bold, false).map(|f| FaceRef::of(&f)).unwrap_or(primary)) };
|
||||
match (run, face) {
|
||||
(Some((cur, from)), Some(f)) if f.id() != cur.id() => {
|
||||
out.extend(s.get(from..i).map(|t| (cur, t)));
|
||||
run = Some((f, i));
|
||||
}
|
||||
(None, f) => run = Some((f.unwrap_or(primary), i)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some((cur, from)) = run {
|
||||
out.extend(s.get(from..).map(|t| (cur, t)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `items` turned by `spin` about the centre of `rect` (as they are, unturned).
|
||||
|
||||
@@ -871,7 +871,8 @@ fn space_after_kept(ctx: &Ctx, block: usize, rp: &wordcraft_doc::resolve::Resolv
|
||||
|
||||
/// The room the paragraphs after `block` need on the page for keep with next: enough of the
|
||||
/// next one to start there — its first line, two of its lines with widow/orphan control (it
|
||||
/// doesn't leave a single line at the bottom of a page; it moves over whole instead), or all
|
||||
/// doesn't leave a single line at the bottom of a page; it moves over whole instead), all three
|
||||
/// of a three-line one under that control (two would leave a widow), or all
|
||||
/// of it under keep lines together — or all of it and what it keeps with in turn when it is
|
||||
/// kept with the next too (a chain moves together in Word). Word carries a kept heading over
|
||||
/// when the paragraph after it cannot start on the page (#966).
|
||||
@@ -925,7 +926,8 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize, counte
|
||||
+ if pl.rp.keep_lines {
|
||||
pl.height
|
||||
} else if pl.rp.widow_control && pl.lines.len() >= 2 {
|
||||
to(1)
|
||||
// Neither split of three lines is allowed (`settled_end`).
|
||||
to(if pl.lines.len() == 3 { 2 } else { 1 })
|
||||
} else {
|
||||
to(0)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,20 @@ pub struct ObjectHit {
|
||||
pub origin: Point,
|
||||
}
|
||||
|
||||
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
|
||||
/// Build once for a batch of lookups; rebuild when the layout changes.
|
||||
pub struct ObjectIndex {
|
||||
by_pos: HashMap<Pos, Vec<ObjectHit>>,
|
||||
}
|
||||
|
||||
impl ObjectIndex {
|
||||
/// Like [`DocLayout::object`]: prefer `page_hint`, otherwise the first matching page.
|
||||
pub fn object(&self, pos: &Pos, page_hint: usize) -> Option<&ObjectHit> {
|
||||
let hits = self.by_pos.get(pos)?;
|
||||
hits.binary_search_by_key(&page_hint, |h| h.page).ok().and_then(|i| hits.get(i)).or_else(|| hits.first())
|
||||
}
|
||||
}
|
||||
|
||||
/// The inputs for a column selection across laid-out lines.
|
||||
pub struct ColumnSelection<'a> {
|
||||
/// The selection's first corner.
|
||||
@@ -456,6 +470,22 @@ impl DocLayout {
|
||||
self.find_object(page_hint, |o| o.story == pos.story && o.path == pos.path && o.off == pos.off)
|
||||
}
|
||||
|
||||
/// Index objects once for repeated [`Self::object`] lookups, preserving their selection
|
||||
/// order, including objects repeated on several pages (headers or repeated table rows).
|
||||
pub fn object_index(&self) -> ObjectIndex {
|
||||
let mut by_pos: HashMap<Pos, Vec<ObjectHit>> = HashMap::new();
|
||||
for (i, page) in self.pages.iter().enumerate() {
|
||||
for hit in all_objects(page, i) {
|
||||
let hits = by_pos.entry(hit.pos()).or_default();
|
||||
// all_objects has the same within-page order as find_object; first wins.
|
||||
if hits.last().is_none_or(|prev| prev.page != i) {
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
ObjectIndex { by_pos }
|
||||
}
|
||||
|
||||
/// Where the text box showing story `part` is laid out.
|
||||
pub fn text_box(&self, part: u32, page_hint: usize) -> Option<ObjectHit> {
|
||||
self.find_object(page_hint, |o| o.text_box == Some(part))
|
||||
@@ -939,3 +969,46 @@ fn score(l: &LineHit, x: f32, y: f32) -> f32 {
|
||||
};
|
||||
dy * 4.0 + dx
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod object_index_tests {
|
||||
use super::*;
|
||||
|
||||
/// Compare with the ordinary lookup, including repeated rows/parts and within-page ties.
|
||||
#[test]
|
||||
fn indexed_objects_keep_page_hints_and_first_match_order() {
|
||||
let body = Pos::body(0, 3);
|
||||
let header = Pos::new(StoryRef::Part(7), Path(vec![0, 0, 0, 1]), 6);
|
||||
let footer = Pos::new(StoryRef::Part(8), Path(vec![0]), 0);
|
||||
let object = |pos: &Pos, x| Placed::Object {
|
||||
rect: Rect::new(x, 20.0, 40.0, 30.0),
|
||||
spin: wordcraft_geom::Spin::default(),
|
||||
story: pos.story,
|
||||
path: pos.path.clone(),
|
||||
off: pos.off,
|
||||
text_box: None,
|
||||
wrap: Wrap::InFrontOfText,
|
||||
origin: Point::new(5.0, 6.0),
|
||||
};
|
||||
let layout = DocLayout {
|
||||
pages: vec![
|
||||
Page { items: vec![object(&body, 10.0), object(&body, 11.0)], header: vec![object(&header, 12.0)], ..Default::default() },
|
||||
Page::default(),
|
||||
Page {
|
||||
items: vec![object(&body, 30.0)],
|
||||
header: vec![object(&header, 31.0), object(&header, 32.0)],
|
||||
footer: vec![object(&footer, 33.0)],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let index = layout.object_index();
|
||||
for pos in [body, header, footer, Pos::body(99, 0), Pos::new(StoryRef::Part(7), Path(vec![0]), 6)] {
|
||||
for hint in [0, 1, 2, 3, usize::MAX] {
|
||||
assert_eq!(index.object(&pos, hint).cloned(), layout.object(&pos, hint), "{pos:?}, page hint {hint}");
|
||||
}
|
||||
}
|
||||
assert!(DocLayout::default().object_index().object(&Pos::body(0, 0), 0).is_none());
|
||||
}
|
||||
}
|
||||
@@ -391,9 +391,9 @@ struct TableFrame {
|
||||
x: f32,
|
||||
}
|
||||
|
||||
fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame {
|
||||
// Nested measurement needs the same preferred weights as layout before sharing span minima.
|
||||
fn preferred_grid(t: &Table, avail: f32) -> Vec<f32> {
|
||||
let ncols = grid_cols(t).max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
||||
// Grid units can be narrower than a text box, including omitted row-end columns.
|
||||
t.grid
|
||||
@@ -408,6 +408,12 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option
|
||||
vec![avail / ncols as f32; ncols]
|
||||
};
|
||||
apply_cell_widths(t, &mut grid);
|
||||
grid
|
||||
}
|
||||
|
||||
fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option<&TableStyleProps>) -> TableFrame {
|
||||
let ncols = grid_cols(t).max(1);
|
||||
let mut grid = preferred_grid(t, avail);
|
||||
let mut total: f32 = grid.iter().sum();
|
||||
// A top-level table in an inherited header or footer takes its share of the defining
|
||||
// section's width (`Ctx::pct_base`).
|
||||
@@ -437,7 +443,7 @@ fn table_frame(ctx: &mut Ctx, t: &Table, avail: f32, depth: usize, style: Option
|
||||
if !t.props.fixed {
|
||||
// Measuring lays paragraphs out once more; it mustn't count their equations twice.
|
||||
let eq_count = ctx.eq_count;
|
||||
let content = column_content(ctx, t, style, depth);
|
||||
let content = column_content(ctx, t, style, depth, &grid);
|
||||
ctx.eq_count = eq_count;
|
||||
if let Some(content) = content {
|
||||
autofit(&mut grid, &content, t, avail);
|
||||
@@ -1167,7 +1173,7 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText {
|
||||
/// Each column's narrowest and widest content, cell margins included: its longest word and its
|
||||
/// longest line. A cell spanning columns shares what they lack between them; nested tables count
|
||||
/// with their own. `None` when every cell is empty.
|
||||
fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option<Vec<(f32, f32)>> {
|
||||
fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize, grid: &[f32]) -> Option<Vec<(f32, f32)>> {
|
||||
let margins_def = default_margins(t, style);
|
||||
// The grid's width once: counting it walks every row.
|
||||
let ncols = grid_cols(t);
|
||||
@@ -1196,15 +1202,36 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
for (g, span, need) in spanning {
|
||||
let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue };
|
||||
let n = cs.len().max(1) as f32;
|
||||
let (have_lo, have_hi) = cs.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1));
|
||||
let have_hi: f32 = cs.iter().map(|c| c.1).sum();
|
||||
let preferred = (!is_automatic(t)).then(|| grid.get(g..g + cs.len())).flatten();
|
||||
share_span_min(cs, preferred, need.0);
|
||||
for c in cs {
|
||||
c.0 += (need.0 - have_lo).max(0.0) / n;
|
||||
c.1 += (need.1 - have_hi).max(0.0) / n;
|
||||
}
|
||||
}
|
||||
any.then_some(cols)
|
||||
}
|
||||
|
||||
// Share a span's extra minimum in preferred-column proportions. Each column keeps
|
||||
// its own text minimum; automatic tables share the extra equally.
|
||||
fn share_span_min(cols: &mut [(f32, f32)], grid: Option<&[f32]>, need: f32) {
|
||||
let extra = (need - cols.iter().map(|c| c.0).sum::<f32>()).max(0.0);
|
||||
let weight = |w: &f32| if w.is_finite() { w.clamp(0.0, UNBOUNDED) } else { 0.0 };
|
||||
let total = grid.map_or(0.0, |grid| grid.iter().take(cols.len()).map(weight).sum::<f32>());
|
||||
if total > 0.0 {
|
||||
if let Some(grid) = grid {
|
||||
for (c, w) in cols.iter_mut().zip(grid) {
|
||||
c.0 += extra * weight(w) / total;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let share = extra / cols.len().max(1) as f32;
|
||||
for c in cols {
|
||||
c.0 += share;
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid columns row `row` leaves empty before its first cell and after its last one (which
|
||||
/// ends at grid column `end`) when it gives their width (`w:wBefore`, `w:wAfter`): (first column,
|
||||
/// columns, width in points). Column widths make room for them like for an empty cell.
|
||||
@@ -1234,7 +1261,8 @@ fn blocks_content(ctx: &mut Ctx, blocks: &Blocks, text: Option<&CellText>, depth
|
||||
(width, width)
|
||||
} else {
|
||||
let style = table_style(ctx, nt);
|
||||
let cols = column_content(ctx, nt, style.as_ref(), depth + 1).unwrap_or_default();
|
||||
let weights = preferred_grid(nt, width);
|
||||
let cols = column_content(ctx, nt, style.as_ref(), depth + 1, &weights).unwrap_or_default();
|
||||
let (l, h) = cols.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1));
|
||||
(width.max(l), width.max(h))
|
||||
}
|
||||
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user