Compare commits

..
Author SHA1 Message Date
Martin BallandClaude Opus 5.5 c3e893a80b CP4.3: review — ROADMAP has one status header again after the merge from main (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 21:19:36 +00:00
Martin Ball 1c25cd7f43 Merge remote-tracking branch 'origin/main' into fix/language-proofing
; Conflicts:
;	ROADMAP.md
;	docs/ui-parity.md
2026-11-03 21:13:09 +00:00
Martin BallandClaude Opus 5.5 d56a3c9ba6 CP4.3: review — a language closed before the list was known closes the pack the whole list has for it (de-CH: Swiss German, not German) (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 19:15:47 +00:00
Martin BallandClaude Opus 5.5 53104c27b9 CP4.3: review — closing a regional pack's bar (de-ch) no longer closes its base language's (de); docs name Add a language… (CodeRabbit)
Tags closed before the list was known are kept apart from closed packs: only a tag maps to the pack it would find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 19:03:30 +00:00
Martin BallandClaude Opus 5.5 78c8a6f600 CP4.3: review — gap 8: without a pack, other languages hyphenate with the English patterns (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 18:31:04 +00:00
Martin BallandClaude Opus 5.5 7aaa2ac4d2 CP4.3: review — Your languages picks the most specific pack, installed or offered, as the bar does: with ca installed, Valencian text still offers ca-valencia (CodeRabbit)
Docs: the missing-pack bar is provisional before the list of packs is known; Belarusian's old label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:45:34 +00:00
Martin BallandClaude Opus 5.5 91a93ff375 CP4.3: review — ROADMAP has one status header; progress notes use the Your languages status names (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:37:11 +00:00
Martin BallandClaude Opus 5.5 a2b1a0d407 CP4.3: review — proof.packs describes its languages rows; docs name every proofing status and scope #8 to built-in proofing (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:28:19 +00:00
Martin BallandClaude Opus 5.5 b513531d46 CP4.3: review — Add a language mirrors right to left; status lines keep main's history; the Polish row no longer claims per-language found lines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:05:28 +00:00
Martin BallandClaude Opus 5.5 9b426294c3 CP4.3: review — docs list every proofing status, Set as default is for new blank documents, proof.packs schema renders (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:42:29 +00:00
Martin BallandClaude Opus 5.5 f117720455 CP4.3: review — Valencian text is on the ca-valencia pack's row in Options › Language, as the bar offers it (CodeRabbit)
locale_for_tag now tries pack_for_tag's candidates in the same order, so the tag without its region (ca-valencia) comes before the region alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:38:07 +00:00
Martin BallandClaude Opus 5.5 9df47ffa77 CP4.3: the missing-pack bar works on a fresh install, counts the caret's language, and the spelling menu heading is in the interface language
With no list of packs cached (a fresh install, or one cached for an older pin) the bar still offers a document language with no dictionary; Install fetches the list, then the pack. With downloading on, a missing or stale list is fetched once by itself. A language closed before the list stays closed after it; a refused get isn't queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:36:41 +00:00
Martin BallandClaude Sonnet 5.5 6aabc503aa CP4.3: Options › Language review: rows follow the editing language, honest Set as default hover, narrow windows, proof.packs languages documented
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:30:39 +00:00
Martin BallandClaude Sonnet 5.5 c87591f1ab CP4.3: Options › Language: Set as default sets only the saved default proofing language (review.language preferredOnly), not the open document or selection
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:30:28 +00:00
Martin BallandClaude Sonnet 5.5 7efebfec08 CP4.3: Options › Language: Your languages with proofing status, Add a language… picker, Set as default; dictionaries folder moves from Proofing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:30:28 +00:00
Martin BallandClaude Opus 5.5 bd58e3cde2 CP4.3: review — Valencian is ca-ES-valencia and finds its pack; a bare pack-language tag (nl) is the nl-NL row, in the menu and the dialog
pack_for_tag tries a variant without its region before the region alone
(ca-ES-valencia -> ca-valencia -> ca-es -> ca), as craft-proof now does.
MORE_LANGUAGES stores Word's ca-ES-valencia; tag_for_pack and language_name
map ca-valencia onto it. language_checks and language_list compare through
same_language: the same tag, or named alike and proofed alike (two pack
languages read from the same pack, or the same locale without its region
when no pack is known), so "nl" no longer shows two Dutch rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:29:45 +00:00
Martin BallandClaude Opus 5.5 00c025b950 CP4.3: one language list: Language pack languages in Set Proofing Language, Review › Language and the status bar; every published pack named in English
Set Proofing Language lists the built-in languages and every installed or
available pack's language (Bulgarian, Belarusian, Spanish (Cuba)...), each
once and sorted by name; Review › Language and the status bar's menu show
the short list plus installed pack languages, checked one way (tag, or the
same ProofLang named alike) instead of hand-written cases. A pack's language
is stored as a tag that finds the pack again: pack_for_tag now tries every
prefix, so sr-Latn-RS is proofed by sr-latn, not the Cyrillic sr.
MORE_LANGUAGES names every locale of language-packs-2 (Belarusian showed as
Беларуская).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:29:45 +00:00
408 changed files with 3669 additions and 41200 deletions
-1
View File
@@ -46,4 +46,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+27 -65
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1103,7 +1103,7 @@ dependencies = [
[[package]]
name = "craft-proof"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
dependencies = [
"log",
"miniz_oxide 0.9.1",
@@ -1520,19 +1520,6 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1600,26 +1587,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -2960,7 +2927,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6785,7 +6752,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -6820,7 +6787,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -6833,11 +6800,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.15.1"
version = "0.15.0"
[[package]]
name = "wordcraft-doc"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"base64 0.23.1",
"log",
@@ -6851,7 +6818,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"cfb",
"chrono",
@@ -6868,7 +6835,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6892,7 +6859,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"cfb",
"chrono",
@@ -6925,7 +6892,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6939,34 +6906,26 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"aes",
"base64 0.23.1",
"cfb",
"chardetng",
"chrono",
"encoding_rs",
"getrandom 0.3.4",
"hmac",
"image",
"js-sys",
"log",
"miniz_oxide 0.9.1",
"proptest",
"quick-xml 0.41.0",
"sha1 0.11.0",
"sha2 0.11.1",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zeroize",
"zip 3.0.0",
]
[[package]]
name = "wordcraft-geom"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"image",
"serde",
@@ -6974,7 +6933,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-proof",
"image",
@@ -6994,7 +6953,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"log",
"serde_json",
@@ -7006,7 +6965,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7014,7 +6973,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -7035,7 +6994,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"image",
"log",
@@ -7049,9 +7008,8 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"accesskit_consumer",
"cfb",
"chrono",
"craft-i18n",
@@ -7059,7 +7017,6 @@ dependencies = [
"craft-update",
"craft-window",
"egui",
"egui_extras",
"egui_kittest",
"image",
"js-sys",
@@ -7082,7 +7039,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"eframe",
"egui",
@@ -7101,7 +7058,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"log",
"proptest",
@@ -7264,7 +7221,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"serde_json",
]
@@ -7623,6 +7580,11 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+6 -9
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.15.1"
version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia
# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also
# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# `language-pack` kind is #486 (CP4.1).
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" }
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
wordcraft-engine = { path = "crates/engine" }
@@ -67,7 +66,7 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
@@ -92,8 +91,6 @@ zeroize = "1"
# dependency of other crates.
libc = "0.2"
# time
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
@@ -134,7 +131,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+6 -15
View File
@@ -11,20 +11,13 @@ _Avoid_: Display language, UI locale
**Proofing language**:
The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation
are checked. A language is told apart from another by its tag (language, script, region, variant),
never by the name a list shows for it.
are checked.
_Avoid_: Text language, editing language, document language
**Language pack**:
A downloadable bundle of a language's spelling dictionary and hyphenation patterns.
_Avoid_: Dictionary download, language data (that is the embedded data)
**Language list**:
The proofing languages offered in Set Proofing Language, Review › Language and the status bar's
menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language,
every available) Language pack, each once.
_Avoid_: Language menu (that is only one of the places it is shown)
**Your languages**:
The proofing languages Options › Language lists: each English variant in use (the default proofing
language and any in the open document), every language with an installed
@@ -32,19 +25,17 @@ Language pack, and every proofing language used in the open document.
_Avoid_: Authoring languages, preferred languages
**Default proofing language**:
The proofing language new blank documents get, set with Set as default; without one, the interface
language.
The proofing language new text gets, set with Set As Default; without one, the interface language.
_Avoid_: Preferred language
**Proofing status**:
Where a language's proofing comes from: built in, an installed Language pack, your own dictionary,
a system dictionary, available to install, or not available.
available to install, or not available.
**Custom dictionary**:
The words you added with Add to Dictionary, accepted in every language.
_Avoid_: User dictionary (in the interface)
**Dictionaries folder**:
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
comes after it).
_Avoid_: Custom dictionaries
**Your own dictionaries folder**:
A folder of Hunspell dictionaries you supply, used before Language packs and the system's.
_Avoid_: Dictionaries folder, custom dictionaries
+6 -89
View File
File diff suppressed because one or more lines are too long
+1 -3
View File
@@ -10,7 +10,7 @@ use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] =
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "ott", "fodt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
@@ -655,8 +655,6 @@ fn mime_for(name: &str) -> &'static str {
Some("pdf") => "application/pdf",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("odt") => "application/vnd.oasis.opendocument.text",
Some("ott") => "application/vnd.oasis.opendocument.text-template",
Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml",
Some("rtf") => "application/rtf",
Some("html") => "text/html",
Some("md" | "txt") => "text/plain",
-2
View File
@@ -239,8 +239,6 @@ fn format_name(ext: &str) -> String {
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"ott" => "OpenDocument Text Template".into(),
"fodt" => "Flat OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
+2 -39
View File
@@ -27,7 +27,7 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
@@ -54,13 +54,7 @@ impl eframe::App for App {
apple_events::poll(&self.5, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let mut window = self.4;
// Only until the off-screen check below has run: it is the one reader.
if self.1.is_some() {
let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point());
window.on_a_monitor = on_a_monitor;
window.primary_monitor_rect = primary_monitor_rect;
}
let window = self.4;
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
@@ -132,37 +126,6 @@ impl eframe::App for App {
}
}
/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds.
/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot
/// report the window or primary monitor (Wayland, headless).
fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option<bool>, Option<egui::Rect>) {
let Some(w) = frame.winit_window() else {
return (None, None);
};
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
return (None, None);
}
let Some(pos) = w.outer_position().ok() else {
return (None, None);
};
let size = w.outer_size();
let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| {
egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32))
};
let monitors: Vec<egui::Rect> =
w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect();
let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors));
let primary_monitor_rect = w.primary_monitor().map(|m| {
let pos = m.position();
let size = m.size();
egui::Rect::from_min_size(
egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point,
egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
)
});
(on_a_monitor, primary_monitor_rect)
}
/// Quit the way Command-Q does, on the next turn of the main queue.
///
/// Called when the window is closing. Doing it on the next turn lets the close
+1 -6
View File
@@ -35,9 +35,7 @@ sync_github_name = false
real_name = "Saeed Azarehgar"
[people.vangel76]
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
sync_github_name = true
[people.dkhlapov]
real_name = "Dmitrii Khlapov"
@@ -189,6 +187,3 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
059cbf95c2a3eb95d637ca8584faca1129d17f48
-1
View File
@@ -1,6 +1,5 @@
//! The control channel's key file, one rule for the app (which writes it) and the MCP bridge
//! (which reads it): `<settings>/control-key.<instance>`. See `docs/control-protocol.md`, Keys.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::path::{Path, PathBuf};
+2 -3
View File
@@ -10,9 +10,8 @@ workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`) and the step count of
# `work::count`; turned on only as a dev-dependency, so never built into
# the app.
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
test-support = []
[dependencies]
+1 -6
View File
@@ -213,12 +213,6 @@ pub struct ShapeExtra {
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
/// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and
/// handles), whose path the shape's freeform was worked out from: saved back as it was, so
/// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with
/// it). Dropped when the shape is made another kind.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf_geometry: Option<std::sync::Arc<String>>,
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -390,6 +384,7 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
(0.0, 1.0)
}
}
ShapeKind::Line => (w, -h),
_ => (w, h),
};
let len = (dx * dx + dy * dy).sqrt();
-12
View File
@@ -254,18 +254,6 @@ pub struct ContentControl {
/// `w:sdtEndPr`, kept verbatim (XML).
#[serde(skip_serializing_if = "String::is_empty")]
pub end_pr_xml: String,
/// The size of the form control it came from (an ODF `draw:control`'s `svg:width` and
/// `svg:height`), points: kept for saving back as one.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_size: Option<[f32; 2]>,
/// How far that form control stands raised from the baseline (points, negative lowered:
/// an ODF control hanging from the baseline is lowered by its height), kept for saving back.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_raise: Option<f32>,
/// A form control WordCraft has no kind for (an ODF button, image button, label…): its form
/// XML, kept verbatim and saved back as it was. It shows its label, locked.
#[serde(skip_serializing_if = "String::is_empty")]
pub form_xml: String,
}
impl ContentControl {
+3 -52
View File
@@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings};
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
use std::sync::Arc;
use super::{BTreeMap, CharProps, Document, InlineObject, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
@@ -62,8 +62,8 @@ impl Document {
body: vec![para_block(Paragraph::new())],
last_section: SectionProps::default(),
parts: BTreeMap::new(),
styles: Arc::new(StyleSheet::builtin()),
numbering: Arc::default(),
styles: StyleSheet::builtin(),
numbering: Numbering::default(),
comments: BTreeMap::new(),
revisions: Vec::new(),
settings: Settings::default(),
@@ -76,7 +76,6 @@ impl Document {
passthrough: BTreeMap::new(),
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
}
}
@@ -153,7 +152,6 @@ impl Document {
passthrough,
glossary,
media_index: _,
kept: _,
} = self;
let Document {
body: other_body,
@@ -173,7 +171,6 @@ impl Document {
passthrough: other_passthrough,
glossary: other_glossary,
media_index: _,
kept: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
@@ -737,52 +734,6 @@ impl Document {
}
}
/// [`Document::objects_mut`] for filling in what an object shows from what it is (a chart's
/// drawing from its data), which doesn't change the document: the markup kept as read for
/// the paragraphs and tables holding them (see [`crate::kept`]) follows them.
pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
// The stories (text boxes', notes') as read before, when filling changes one: they stay
// as read after it, so the paragraphs showing them keep their markup.
let holds = |b: &Arc<Block>| {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
any
};
let as_read: Vec<u32> = match self.kept.get() {
Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => {
k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect()
}
_ => Vec::new(),
};
let mut moves: Vec<crate::kept::Moved> = Vec::new();
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
if !any {
continue;
}
let before = crate::kept::identities(b);
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
if p.objects.iter().any(wants) {
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
p.touch();
}
});
moves.push((before, crate::kept::identities(b)));
}
}
let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect();
if let Some(k) = self.kept.get_mut() {
for (before, after) in moves {
k.follow(&before, &after);
}
k.story_fps.extend(refreshed);
}
}
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
/// JSON, whose run lengths may overflow, fall short or split a character.
pub fn normalize_runs(&mut self) {
+4 -4
View File
@@ -1054,7 +1054,7 @@ impl Document {
let mut default_style = None;
if keep_styles {
if src.styles.get("Normal").is_none() {
Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
}
// Ids given out here are taken too: two styles never get the same new one.
let mut given = std::collections::HashSet::new();
@@ -1089,7 +1089,7 @@ impl Document {
abs_map.insert(a.id, id);
let mut a = crate::numbering::AbstractNum { id, ..a.clone() };
a.levels.iter_mut().for_each(|l| restyle(&mut l.style));
Arc::make_mut(&mut self.numbering).abstracts.push(a);
self.numbering.abstracts.push(a);
}
let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
let mut nums = BTreeMap::new();
@@ -1099,7 +1099,7 @@ impl Document {
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() };
n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style));
Arc::make_mut(&mut self.numbering).nums.push(n);
self.numbering.nums.push(n);
}
let renumber = |n: &mut Option<crate::props::NumRef>| {
if let Some(n) = n.as_mut()
@@ -1138,7 +1138,7 @@ impl Document {
}
if self.styles.get(&st.id).is_none() {
renumber(&mut st.para.numbering);
Arc::make_mut(&mut self.styles).upsert(st);
self.styles.upsert(st);
}
}
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
+19 -21
View File
@@ -663,15 +663,13 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
#[test]
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
// The deepest nesting allowed, each table in the first cell of the one around it, ahead of
// that cell's paragraph: the innermost paragraphs come first in the document but the
// paragraph walk stops short.
const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) };
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
// the innermost paragraphs come first in the document but the paragraph walk stops short.
let mut t = Table::new(1, 1, 200.0);
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
}
for _ in 1..MAX_TABLE_DEPTH {
for _ in 0..20 {
let mut outer = Table::new(1, 1, 200.0);
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks.insert(0, Arc::new(Block::Table(t)));
@@ -692,19 +690,19 @@ fn adopting_a_document_renumbers_what_it_refers_to() {
let mut ours = doc(&["ours"]);
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
let mut theirs = doc(&["theirs"]);
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
assert_eq!(list, our_list);
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
Arc::make_mut(&mut theirs.styles).upsert(style);
theirs.styles.upsert(style);
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
p.runs[0].props.del = Some(0);
@@ -748,11 +746,11 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
use crate::styles::{Style, StyleKind, StyleSheet};
let mut ours = doc(&["ours"]);
let mut theirs = doc(&["theirs"]);
theirs.styles = Arc::new(StyleSheet::empty());
Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() };
theirs.styles = StyleSheet::empty();
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
let big = CharProps { size: Some(20.0), ..Default::default() };
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.style = Some("Big".into());
p.runs[0].props.style = Some("Link".into());
@@ -766,15 +764,15 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
use crate::styles::Style;
let mut ours = doc(&["ours"]);
Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let mut theirs = doc(&["theirs"]);
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered);
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap();
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
a.levels[0].style = Some("Heading1".into());
let level = a.levels[0].clone();
Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
let before = ours.numbering.abstracts.len();
ours.adopt_with(theirs, true);
@@ -814,10 +812,10 @@ fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
use crate::{Revision, RevisionKind};
for keep in [false, true] {
let mut ours = doc(&["ours"]);
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
let mut theirs = doc(&["theirs"]);
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
assert_eq!(list, our_list);
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
// Live and old props name the same styles (ours has them too) and their list.
@@ -845,8 +843,8 @@ fn adopted_styles_never_become_our_defaults() {
let mut ours = doc(&["ours"]);
let mut theirs = doc(&["theirs"]);
// A localized default character style, and one whose id we have too.
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
ours.adopt_with(theirs, keep);
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
}
-11
View File
@@ -51,17 +51,6 @@ pub struct Embedded {
pub refs: Vec<EmbeddedRef>,
}
impl Embedded {
/// The root element of an ODF embedded object's markup: its files are kept in
/// [`crate::Document::passthrough`] for the ODT writer; DOCX shows it as its picture.
pub const ODF_OBJECT: &'static str = "<wordcraft:odf-object";
/// Is it an ODF embedded object (a spreadsheet in an ODT), not Word markup?
pub fn is_odf_object(&self) -> bool {
self.xml.trim_start().starts_with(Self::ODF_OBJECT)
}
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedRef {
-780
View File
@@ -1,780 +0,0 @@
//! Markup kept from the file a document was opened from, so that what isn't edited is written
//! back as it was: a paragraph keeps its source XML, which a writer uses as long as the
//! paragraph still is what the reader made of it (its [`fingerprint`] unchanged). Anything the
//! model has no place for (an unknown element, an attribute nobody reads) survives a save that
//! way. Never saved with the document (WordCraft's own format), and not part of its content
//! (`==` ignores it).
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use crate::Paragraph;
/// Most kept markup per document, bytes (a hostile file can't make the kept copy grow without
/// bound: past it, paragraphs are written from the model as before).
pub const MAX_KEPT_BYTES: usize = 64 * 1024 * 1024;
/// The kept markup of a document (see the module docs).
#[derive(Clone, Debug, Default)]
pub struct Kept(Option<Arc<KeptXml>>);
/// What a reader kept.
#[derive(Clone, Debug, Default)]
pub struct KeptXml {
/// The format it came from (`"odt"`): only that format's writer uses it.
pub format: String,
/// Paragraph [`fingerprint`] → the paragraph's markup, in the order read (identical
/// paragraphs keep the first's).
pub paras: HashMap<u64, Arc<str>>,
/// The same for the body's tables ([`table_fingerprint`]).
pub tables: HashMap<u64, Arc<str>>,
/// While reading: the markup of the body's `n`th table, until [`Kept::seal`].
pub pending_tables: Vec<(usize, Arc<str>)>,
/// Wrappers kept as read around paragraphs (sections): for a paragraph's fingerprint, the
/// start tags opening before it and how many close after it. Only paragraphs that were the
/// only one of their kind have them (a copy can't open a section twice).
pub wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// The same for paragraphs with twins (an empty paragraph has many): by fingerprint and
/// [`Paragraph::rev`], which stays while the paragraph does (a writer opens each once).
pub twin_wraps: HashMap<(u64, u64), (Vec<Arc<str>>, u32)>,
/// The same wrappers around the body's tables, by [`table_fingerprint`] (tables that are
/// the only one of their kind).
pub table_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: those of the body's `n`th table, until `seal_kept`.
pub pending_table_wraps: Vec<(usize, (Vec<Arc<str>>, u32))>,
/// Indexes kept as read: by their first paragraph's fingerprint, how many top-level
/// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]),
/// and the index's markup. Written as read while those paragraphs are all as read.
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
/// The group of paragraphs written as read together (see `wordcraft_formats::model::Group`)
/// each paragraph kept belongs to, by its fingerprint: the group's id and size.
pub groups: HashMap<u64, (u32, u32)>,
/// While reading: the same by paragraph `rev`, until `seal_kept`.
pub pending_groups: HashMap<u64, (u32, u32)>,
/// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph
/// showing one is written as read only while it is as read.
pub story_fps: HashMap<u32, u64>,
/// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a
/// paragraph holding comments is written as read only while they are as read.
pub comment_fps: HashMap<u32, u64>,
/// Named definitions the kept markup refers to (styles, font faces…), by a key the format
/// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read.
pub defs: BTreeMap<String, Arc<str>>,
/// While reading: paragraph [`Paragraph::rev`] → its markup, until [`Kept::seal`] turns
/// them into fingerprints.
pub pending: HashMap<u64, Arc<str>>,
/// Bytes kept so far.
pub bytes: usize,
/// The keys of [`KeptXml::defs`] in the order the file had them.
pub order: Vec<String>,
/// Files of the package kept definitions refer to (a style's fill picture), by path.
pub files: BTreeMap<String, Arc<Vec<u8>>>,
/// The document's style definitions' [`styles_fingerprint`] when read: while it is the
/// same, the file's own style definitions can be written as they were.
pub styles_fp: Option<u64>,
/// The document's page setup's [`pages_fingerprint`] when read: while it is the same, the
/// file's own page layouts and master pages can be written as they were.
pub pages_fp: Option<u64>,
/// Each section's master page, as the file named it where its section starts (`None`: the
/// file's default), for a writer naming the file's own master pages.
pub section_masters: Vec<Option<String>>,
}
impl PartialEq for Kept {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Kept {
pub fn new(x: KeptXml) -> Kept {
Kept(Some(Arc::new(x)))
}
pub fn get(&self) -> Option<&KeptXml> {
self.0.as_deref()
}
/// What `format`'s writer may use, if anything was kept from such a file.
pub fn of(&self, format: &str) -> Option<&KeptXml> {
self.get().filter(|k| k.format == format)
}
pub fn get_mut(&mut self) -> Option<&mut KeptXml> {
self.0.as_mut().map(Arc::make_mut)
}
pub fn clear(&mut self) {
self.0 = None;
}
/// After reading: each paragraph still marked pending (by its `rev`, which any change after
/// the reader made it would have moved on) gets its markup under its fingerprint.
pub fn seal(&mut self, paras: &mut dyn Iterator<Item = &Paragraph>) {
let Some(k) = self.get_mut() else { return };
if k.pending.is_empty() {
return;
}
for p in paras {
if let Some(xml) = k.pending.remove(&p.rev) {
k.paras.entry(fingerprint(p)).or_insert(xml);
}
}
k.pending.clear();
}
}
impl KeptXml {
/// The markup kept for paragraph `p`, while it still is what was read.
pub fn para(&self, p: &Paragraph) -> Option<&Arc<str>> {
if self.paras.is_empty() {
return None;
}
self.paras.get(&fingerprint(p))
}
/// The markup kept for table `t`, while it still is what was read.
pub fn table(&self, t: &crate::Table) -> Option<&Arc<str>> {
if self.tables.is_empty() {
return None;
}
self.tables.get(&table_fingerprint(t))
}
/// Whether the comments paragraph `p` holds (those it begins, with their replies) are as
/// read and are all the comments its kept markup `xml` holds: a reply written elsewhere
/// in the file, or one added since, isn't in it.
pub fn comments_as_read(&self, doc: &crate::Document, p: &Paragraph, xml: &str) -> bool {
let in_markup = xml.matches("<office:annotation ").count() + xml.matches("<office:annotation>").count();
let mut count = 0usize;
for o in &p.objects {
if let crate::para::InlineObject::CommentStart { id } = o {
let (fp, n) = comments_fingerprint(doc, *id);
if self.comment_fps.get(id) != Some(&fp) {
return false;
}
count = count.saturating_add(n);
}
}
count == in_markup
}
/// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are
/// as read.
pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool {
// A note's citation is its number in the document's numbering, which is the page setup's.
let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. }));
if notes && !self.pages_as_read(doc) {
return false;
}
p.objects
.iter()
.flat_map(|o| match o {
crate::para::InlineObject::NoteRef { id, .. } => vec![*id],
o => o.text_boxes(),
})
.all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id)))
}
/// The wrappers kept around table `t`, while it still is what was read.
pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec<Arc<str>>, u32)> {
if self.table_wraps.is_empty() {
return None;
}
self.table_wraps.get(&table_fingerprint(t))
}
/// The wrappers kept around paragraph `p`, while it still is what was read.
pub fn wrap(&self, p: &Paragraph) -> Option<&(Vec<Arc<str>>, u32)> {
if self.wraps.is_empty() && self.twin_wraps.is_empty() {
return None;
}
let fp = fingerprint(p);
self.wraps.get(&fp).or_else(|| self.twin_wraps.get(&(fp, p.rev)))
}
/// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their
/// [`run_fingerprint`] when read, its markup.
pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc<str>)> {
if self.indexes.is_empty() {
return None;
}
self.indexes.get(&fingerprint(p))
}
/// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`.
pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_indexes.insert(rev, (n, xml));
}
}
/// The paragraph whose `rev` is `rev` belongs to group `group` (id, size).
pub fn keep_pending_group(&mut self, rev: u64, group: (u32, u32)) {
if self.pending_groups.len() < 100_000 {
self.pending_groups.insert(rev, group);
}
}
/// The group paragraph `p`'s markup kept as read belongs to (id, size).
pub fn group(&self, p: &Paragraph) -> Option<(u32, u32)> {
if self.groups.is_empty() {
return None;
}
self.groups.get(&fingerprint(p)).copied()
}
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(n) <= MAX_KEPT_BYTES {
self.bytes += n;
self.pending_wraps.insert(rev, wrap);
}
}
/// Keep wrapper markup for the body's `n`th table, within the budget.
pub fn keep_pending_table_wrap(&mut self, n: usize, wrap: (Vec<Arc<str>>, u32)) {
let size: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(size) <= MAX_KEPT_BYTES {
self.bytes += size;
self.pending_table_wraps.push((n, wrap));
}
}
/// Keep `xml` for the body's `n`th table, within the budget.
pub fn keep_pending_table(&mut self, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_tables.push((n, xml));
}
}
/// Keep `xml` for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending(&mut self, rev: u64, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending.insert(rev, xml);
}
}
/// Keep a named definition, within the budget.
pub fn keep_def(&mut self, key: String, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES && !self.defs.contains_key(&key) {
self.bytes += xml.len();
self.order.push(key.clone());
self.defs.insert(key, xml);
}
}
/// Keep a file kept definitions refer to, within the budget.
pub fn keep_file(&mut self, path: String, bytes: Vec<u8>) {
if self.bytes.saturating_add(bytes.len()) <= MAX_KEPT_BYTES && !self.files.contains_key(&path) {
self.bytes += bytes.len();
self.files.insert(path, Arc::new(bytes));
}
}
/// Whether `doc`'s page setup is still as it was read.
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
let Some(fp) = self.pages_fp else { return false };
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
fp == now
}
/// Whether `doc`'s style definitions are still as they were read.
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
self.styles_fp.is_some_and(|fp| fp == styles_fingerprint(doc))
}
}
/// What `doc`'s style definitions are: its styles, lists and default tab interval.
pub fn styles_fingerprint(doc: &crate::Document) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(&doc.styles, &doc.numbering, doc.settings.default_tab)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a block is, as the kept markup knows it: its paragraphs' fingerprints and
/// [`Paragraph::rev`]s in order, and a table's [`table_fingerprint`].
pub struct Identities {
paras: Vec<(u64, u64)>,
table: Option<u64>,
}
/// A block's [`Identities`] before and after a change that keeps what it is.
pub type Moved = (Identities, Identities);
/// The [`Identities`] of block `b`.
pub fn identities(b: &crate::Block) -> Identities {
let mut paras_of = Vec::new();
match b {
crate::Block::Para(p) => paras_of.push(p),
crate::Block::Table(t) => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, 1, &mut paras_of);
}
}
}
}
Identities { paras: paras_of.iter().map(|p| (fingerprint(p), p.rev)).collect(), table: b.as_table().map(table_fingerprint) }
}
impl KeptXml {
/// The markup kept for a block's paragraphs and table follows them from `before` to `after`
/// (what they were and are: see [`crate::Document::fill_objects`]).
pub fn follow(&mut self, before: &Identities, after: &Identities) {
for ((fp, rev), (nfp, nrev)) in before.paras.iter().zip(&after.paras) {
if fp == nfp && rev == nrev {
continue;
}
if let Some(x) = self.paras.remove(fp) {
self.paras.entry(*nfp).or_insert(x);
}
if let Some(w) = self.wraps.remove(fp) {
self.wraps.entry(*nfp).or_insert(w);
}
if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) {
self.twin_wraps.entry((*nfp, *nrev)).or_insert(w);
}
// Its group too: all a group's paragraphs are written as read, or none.
if let Some(g) = self.groups.remove(fp) {
self.groups.entry(*nfp).or_insert(g);
}
}
if let (Some(t), Some(nt)) = (before.table, after.table)
&& t != nt
{
if let Some(x) = self.tables.remove(&t) {
self.tables.entry(nt).or_insert(x);
}
if let Some(w) = self.table_wraps.remove(&t) {
self.table_wraps.entry(nt).or_insert(w);
}
}
}
}
/// How deep [`story_fingerprint`] follows stories in stories (text boxes in text boxes, notes).
const MAX_STORY_DEPTH: usize = 8;
/// What story `id` (a text box's or a shape's text) is: its blocks, and the stories and notes
/// they hold in turn.
pub fn story_fingerprint(doc: &crate::Document, id: u32) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut todo = vec![(id, 0usize)];
let mut seen = std::collections::HashSet::new();
while let Some((id, depth)) = todo.pop() {
if !seen.insert(id) {
continue;
}
let blocks = doc.parts.get(&id).map(|p| &p.blocks);
match serde_json::to_vec(&(id, blocks)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
if depth >= MAX_STORY_DEPTH {
continue;
}
let Some(blocks) = blocks else { continue };
let mut held = Vec::new();
paras(blocks, 0, &mut held);
for p in held {
for o in &p.objects {
todo.extend(o.text_boxes().into_iter().map(|s| (s, depth + 1)));
if let crate::para::InlineObject::NoteRef { id, .. } = o {
todo.push((*id, depth + 1));
}
}
}
}
h.finish()
}
/// Most replies a comment's fingerprint follows (hostile input).
const MAX_REPLIES: usize = 10_000;
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
/// text, and how many they are.
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
family_fingerprint(doc, root, &scoped_replies(doc))
}
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
struct Scope {
/// The document's address: it can't move or change while borrowed for the writing.
doc: usize,
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
pages: Option<u64>,
}
thread_local! {
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
thread_local! {
/// How many times [`replies`] was built (tests).
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let prev = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = prev);
}
}
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
let key = std::ptr::from_ref(doc) as usize;
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
let _restore = Restore(prev);
f()
}
/// The scope for `doc`, if it is being written: `f` of it.
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
let key = std::ptr::from_ref(doc) as usize;
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
}
/// [`replies`], built once while `doc` is being written.
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
}
/// Each comment's replies, by the comment's id.
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
#[cfg(test)]
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
for (id, c) in &doc.comments {
if let Some(p) = c.parent {
out.entry(p).or_default().push(*id);
}
}
out
}
/// [`comments_fingerprint`], with each comment's `replies`.
fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, Vec<u32>>) -> (u64, usize) {
let mut ids = vec![root];
let mut seen = std::collections::HashSet::from([root]);
let mut i = 0;
while let Some(&id) = ids.get(i) {
for r in replies.get(&id).map(Vec::as_slice).unwrap_or(&[]) {
// (A reply naming itself, or a circle of them, is followed once.)
if ids.len() < MAX_REPLIES && seen.insert(*r) {
ids.push(*r);
}
}
i += 1;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for id in &ids {
let c = doc.comments.get(id);
let text = c.and_then(|c| doc.parts.get(&c.part)).map(|p| &p.blocks);
match serde_json::to_vec(&(c, text)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
}
(h.finish(), ids.len())
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
let parts: Vec<(&u32, &crate::Part)> =
doc.parts.iter().filter(|(_, p)| matches!(p.kind, crate::PartKind::Header | crate::PartKind::Footer)).collect();
let s = &doc.settings;
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(
sections,
parts,
&s.page_color,
&s.page_picture,
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a run of paragraphs is in the model, together.
pub fn run_fingerprint<'a>(paras: impl Iterator<Item = &'a Paragraph>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in paras {
fingerprint(p).hash(&mut h);
}
h.finish()
}
/// What table `t` is in the model.
pub fn table_fingerprint(t: &crate::Table) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(t) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What paragraph `p` is in the model (its text, runs, objects and properties, not its `rev`):
/// equal for a paragraph nobody changed.
pub fn fingerprint(p: &Paragraph) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(p) {
Ok(v) => v.hash(&mut h),
// Unserializable (no paragraph is): a value no paragraph's markup is kept under.
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// Deepest table nesting [`crate::Document::seal_kept`] looks into.
const MAX_SEAL_DEPTH: usize = 16;
fn paras<'a>(bl: &'a crate::Blocks, depth: usize, out: &mut Vec<&'a Paragraph>) {
for b in bl {
match &**b {
crate::Block::Para(p) => out.push(p),
crate::Block::Table(t) if depth < MAX_SEAL_DEPTH => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, depth + 1, out);
}
}
}
crate::Block::Table(_) => {}
}
}
}
impl crate::Document {
/// After reading: the markup kept for paragraphs still as the reader made them is filed
/// under their fingerprints (see [`Kept::seal`]); the rest is dropped.
pub fn seal_kept(&mut self) {
// The body's tables kept, by their place among its tables; its indexes, by their run of
// top-level paragraphs.
if let Some(k) = self.kept.get_mut() {
let pending = std::mem::take(&mut k.pending_indexes);
for (i, b) in self.body.iter().enumerate() {
let Some(p) = b.as_para() else { continue };
let Some((n, xml)) = pending.get(&p.rev) else { continue };
let run: Option<Vec<&Paragraph>> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect());
if let Some(run) = run.filter(|r| r.len() == *n) {
k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone()));
}
}
let pending = std::mem::take(&mut k.pending_tables);
let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect();
for (n, xml) in pending {
if let Some(t) = tables.get(n) {
k.tables.entry(table_fingerprint(t)).or_insert(xml);
}
}
// Wrappers, for tables that are the only one of their kind (a copy can't open a
// section twice).
let pending = std::mem::take(&mut k.pending_table_wraps);
if !pending.is_empty() {
let fps: Vec<u64> = tables.iter().map(|t| table_fingerprint(t)).collect();
for (n, w) in pending {
if let Some(fp) = fps.get(n)
&& fps.iter().filter(|f| *f == fp).count() == 1
{
k.table_wraps.insert(*fp, w);
}
}
}
}
// The text boxes' and shapes' stories as read, for paragraphs showing them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) {
let fps: HashMap<u32, u64> = self
.parts
.iter()
.filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote))
.map(|(id, _)| (*id, story_fingerprint(self, *id)))
.collect();
if let Some(k) = self.kept.get_mut() {
k.story_fps = fps;
}
}
// The comments as read, for paragraphs holding them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) && !self.comments.is_empty() {
let replies = replies(self);
let fps: HashMap<u32, u64> =
self.comments.iter().filter(|(_, c)| c.parent.is_none()).map(|(id, _)| (*id, family_fingerprint(self, *id, &replies).0)).collect();
if let Some(k) = self.kept.get_mut() {
k.comment_fps = fps;
}
}
if self.kept.get().is_none_or(|k| k.pending.is_empty() && k.pending_wraps.is_empty()) {
return;
}
let mut all = Vec::new();
paras(&self.body, 0, &mut all);
for p in self.parts.values() {
paras(&p.blocks, 0, &mut all);
}
let mut kept = std::mem::take(&mut self.kept);
// Groups, by fingerprint; a group with a paragraph that has twins (which a writer can't
// tell apart) isn't kept.
if let Some(k) = kept.get_mut()
&& !k.pending_groups.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
let pending = std::mem::take(&mut k.pending_groups);
let mut broken = std::collections::HashSet::new();
let mut members: Vec<(u64, u64, (u32, u32))> = Vec::new();
for (rev, fp) in &fps {
if let Some(g) = pending.get(rev) {
if count.get(fp) != Some(&1) {
broken.insert(g.0);
}
members.push((*rev, *fp, *g));
}
}
for (rev, fp, g) in members {
if broken.contains(&g.0) {
k.pending.remove(&rev);
} else {
k.groups.insert(fp, g);
}
}
}
// Wrappers, for paragraphs that are the only one of their kind.
if let Some(k) = kept.get_mut()
&& !k.pending_wraps.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
for (rev, fp) in fps {
if let Some(w) = k.pending_wraps.remove(&rev) {
if count.get(&fp) == Some(&1) {
k.wraps.insert(fp, w);
} else {
k.twin_wraps.insert((fp, rev), w);
}
}
}
k.pending_wraps.clear();
}
kept.seal(&mut all.into_iter());
self.kept = kept;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_paragraph_keeps_its_markup_until_it_changes() {
let mut p = Paragraph::with_text("Hello", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hello</text:p>"));
}
kept.seal(&mut std::iter::once(&p));
let k = kept.of("odt").unwrap();
assert_eq!(k.para(&p).map(|x| &**x), Some("<text:p>Hello</text:p>"));
assert!(k.pending.is_empty());
// The same paragraph again (a copy, its rev moved on): still what was read.
p.touch();
assert!(k.para(&p).is_some());
// Changed: written from the model.
p.text.push('!');
assert!(k.para(&p).is_none());
assert!(kept.of("docx").is_none());
}
#[test]
fn a_paragraph_changed_while_reading_keeps_nothing() {
let mut p = Paragraph::with_text("Hi", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hi</text:p>"));
}
p.touch();
kept.seal(&mut std::iter::once(&p));
assert!(kept.of("odt").unwrap().paras.is_empty());
}
#[test]
fn kept_markup_is_bounded() {
let mut k = KeptXml::default();
let big: Arc<str> = Arc::from("x".repeat(MAX_KEPT_BYTES / 2 + 1));
k.keep_pending(1, big.clone());
k.keep_pending(2, big);
assert_eq!(k.pending.len(), 1);
}
/// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data)
/// doesn't change the document: a paragraph's group and a text box's story stay as read.
#[test]
fn filling_objects_keeps_groups_and_stories_as_read() {
use crate::para::InlineObject;
let field =
|| InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
let mut doc = crate::Document::from_text("Body");
let mut boxed = Paragraph::with_text("In the box", Default::default());
boxed.insert_object(0, field(), &Default::default()).unwrap();
let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]);
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap();
let fp = fingerprint(doc.body[0].as_para().unwrap());
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
k.paras.insert(fp, Arc::from("<text:p>Body</text:p>"));
k.groups.insert(fp, (1, 1));
k.story_fps.insert(id, story_fingerprint(&doc, id));
doc.kept = Kept::new(k);
doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| {
if let InlineObject::Field { result, .. } = o {
*result = "filled".into();
}
});
let p = doc.body[0].as_para().unwrap();
assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved");
let k = doc.kept.get().unwrap();
assert!(k.para(p).is_some());
assert_eq!(k.group(p), Some((1, 1)));
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
}
/// Writing a document checks each kept paragraph's comments against what was read: the
/// comments' replies are looked up once for the whole document, not once per comment.
#[test]
fn writing_many_comments_looks_their_replies_up_once() {
use crate::para::InlineObject;
let n = 50u32;
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
let mut doc = crate::Document::from_text(&text);
for i in 0..n {
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
.unwrap()
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
.unwrap();
}
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
for i in 0..n {
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
}
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
let before = REPLY_BUILDS.with(std::cell::Cell::get);
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
assert!(all, "every paragraph's comment is as read");
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
// Outside a writing, nothing is kept between calls.
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
}
}
+6 -17
View File
@@ -2,9 +2,9 @@
//!
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
//! table cells hold block lists of their own. Blocks, the style sheet and the list definitions
//! are `Arc`-shared, so cloning a document is cheap (undo snapshots, handing a frozen copy to the
//! renderer) and edits copy only what they touch (`Arc::make_mut`).
//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is
//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they
//! touch (`Arc::make_mut`).
//!
//! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths
//! compare in document order.
@@ -28,7 +28,6 @@ pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod kept;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
@@ -40,7 +39,6 @@ pub mod math_linear;
pub mod math_symbols;
pub mod numbering;
pub mod para;
pub mod picfx;
pub mod props;
pub mod resolve;
pub mod section;
@@ -49,7 +47,6 @@ pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod work;
pub mod wrap;
pub mod zip_guard;
@@ -87,7 +84,7 @@ pub use error::{DocError, Result};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub use text_walk::count_words;
@@ -101,12 +98,8 @@ pub struct Document {
/// Properties of the last section (OOXML's body-level `w:sectPr`).
pub last_section: SectionProps,
pub parts: BTreeMap<u32, Part>,
/// Shared, like the blocks: an undo step copies it only when a command changes it (a full
/// copy is kilobytes a style, so typing into a document with hundreds of styles cost megabytes
/// a step).
pub styles: Arc<StyleSheet>,
/// Shared like [`Document::styles`].
pub numbering: Arc<Numbering>,
pub styles: StyleSheet,
pub numbering: Numbering,
pub comments: BTreeMap<u32, Comment>,
pub revisions: Vec<Revision>,
pub settings: Settings,
@@ -134,10 +127,6 @@ pub struct Document {
/// by `==`).
#[serde(skip)]
media_index: MediaIndex,
/// Markup kept from the file it was opened from, for writing back what isn't edited (see
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
}
#[cfg(test)]
+2 -114
View File
@@ -1,6 +1,5 @@
//! Hyperlink targets: where a `HYPERLINK` field code points, and which targets may leave the
//! document: followed by the OS, or written into an exported HTML/Markdown file where a browser
//! would run them.
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
//! exported HTML/Markdown file where a browser would run them.
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
@@ -40,121 +39,10 @@ fn scheme(url: &str) -> Option<String> {
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
}
/// Where a `HYPERLINK` field code points, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`)
/// or `#bookmark`. None for any other field, or one with no target.
pub fn hyperlink_target(instr: &str) -> Option<String> {
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
if !instr.trim_start().get(..9).is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let args = field_args(instr);
let mut args = args.iter().peekable();
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let is_switch = |(a, quoted): &&(String, bool)| !quoted && a.starts_with('\\');
let (mut url, mut anchor) = (None, None);
while let Some(arg @ (a, _)) = args.next() {
if !is_switch(&arg) {
url = url.or(Some(a.as_str()));
continue;
}
// `\l` names a bookmark, `\o` a tooltip, `\t` a frame, `\*` `\#` `\@` a format; `\m` and `\n` take none.
let takes = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t" | "\\*" | "\\#" | "\\@");
let value = takes.then(|| args.next_if(|v| !is_switch(v))).flatten();
if a.eq_ignore_ascii_case("\\l")
&& let Some((v, _)) = value
{
anchor = Some(v.as_str());
}
}
let url = url.map(str::trim).filter(|u| !u.is_empty());
let anchor = anchor.map(|a| a.trim().trim_start_matches('#')).filter(|a| !a.is_empty());
match (url, anchor) {
// `\l` replaces a fragment the address already has, as in Word.
(Some(u), Some(a)) => Some(format!("{}#{a}", u.split_once('#').map_or(u, |(base, _)| base))),
(Some(u), None) => Some(u.to_string()),
(None, Some(a)) => Some(format!("#{a}")),
(None, None) => None,
}
}
/// A field code's words: the field's name, its switches and its arguments, each with whether
/// it was quoted. In quotes a backslash escapes the next character, as in Word.
fn field_args(instr: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
let mut word = String::new();
if c == '"' {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' => word.extend(chars.next()),
_ => word.push(c),
}
}
out.push((word, true));
} else {
word.push(c);
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
word.push(c);
}
out.push((word, false));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// Every reader's `HYPERLINK` cases: switches with and without values, quotes, escapes, spelling.
#[test]
fn hyperlink_field_codes_name_their_targets() {
for (code, want) in [
(r#"HYPERLINK "http://example.com" \l "heading""#, Some("http://example.com#heading")),
(r#"HYPERLINK \l "heading""#, Some("#heading")),
(r#"HYPERLINK \l "_Toc1" "#, Some("#_Toc1")),
(r#"Hyperlink "http://example.com/a path""#, Some("http://example.com/a path")),
("HYPERLINK https://example.org/b", Some("https://example.org/b")),
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
// Switches that take a value, before or after the address, and those that take none.
(r#"HYPERLINK "http://example.com" \o "Help text""#, Some("http://example.com")),
(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#, Some("https://example.org/")),
(r#"HYPERLINK \l "bm" \o "tip""#, Some("#bm")),
(r#"HYPERLINK \l "Sec" \m"#, Some("#Sec")),
(r#"HYPERLINK \n "http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK \l "x" \* MERGEFORMAT"#, Some("#x")),
(r#"HYPERLINK "http://example.com" \l \o "tip""#, Some("http://example.com")),
(r#"HYPERLINK \l "bm" \l"#, Some("#bm")),
// A bookmark's own `#`, an address that has a fragment, and an empty address.
(r##"HYPERLINK "http://example.com" \l "#top""##, Some("http://example.com#top")),
(r#"HYPERLINK "http://example.com/#a" \l "b""#, Some("http://example.com/#b")),
(r#"HYPERLINK "" \l "x""#, Some("#x")),
// No target, or not a `HYPERLINK` field.
("HYPERLINK", None),
(r#"HYPERLINK "" \o "tip""#, None),
(r#"HYPERLINKS "https://example.org/""#, None),
(r#"HYPERLINKX "http://example.com""#, None),
(r#"HYPERLINK\l "x""#, None),
("PAGEREF _Toc1 \\h", None),
("ab日本語", None),
("", None),
] {
assert_eq!(hyperlink_target(code).as_deref(), want, "{code:?}");
}
}
#[test]
fn only_web_and_mail_links_are_followable() {
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
+3 -3
View File
@@ -231,7 +231,7 @@ impl Paragraph {
/// Number of objects in `text[..off]`.
fn objects_before(&self, off: usize) -> usize {
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
}
/// The object at byte `off` (which must hold U+FFFC).
@@ -250,13 +250,13 @@ impl Paragraph {
}
/// Byte offsets of all objects, in order.
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
-3
View File
@@ -19,9 +19,6 @@ pub enum InlineObject {
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
fx: crate::picfx::PicFx,
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
-190
View File
@@ -1,190 +0,0 @@
//! Picture colour corrections read from a file: brightness, contrast and transparency
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
//!
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
//! the picture as it is.
//!
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
//! only at the end:
//!
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
//!
//! (as if half the brightness shift were applied before the contrast and half after). Measured
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
//!
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
use serde::{Deserialize, Serialize};
/// The largest magnitude of a correction (100 %).
pub const FX_MAX: i32 = 100_000;
/// Brightness, contrast and transparency of a picture (see the module docs).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct PicFx {
/// −100 000 (black) ..= 100 000 (white).
pub bright: i32,
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
pub contrast: i32,
/// 0 (opaque) ..= 100 000 (invisible).
pub transparency: i32,
}
impl PicFx {
/// Corrections clamped to their ranges.
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
}
/// The same corrections, each clamped to its range (for values that came from anywhere).
pub fn clamped(self) -> PicFx {
PicFx::new(self.bright, self.contrast, self.transparency)
}
/// No correction at all.
pub fn is_identity(&self) -> bool {
*self == PicFx::default()
}
/// Brightness or contrast changes the pixels (transparency alone doesn't).
pub fn changes_pixels(&self) -> bool {
let f = self.clamped();
f.bright != 0 || f.contrast != 0
}
/// The picture's opacity, 0..=1 (1 − transparency).
pub fn opacity(&self) -> f32 {
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
}
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
/// black level (see the module docs). Non-finite values count as missing.
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
PicFx::new(fx(2.0 * black), fx(c), 0)
}
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
pub fn to_vml(&self) -> (f64, f64) {
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
}
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
fn slope(&self) -> f64 {
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
}
/// The lookup table every colour channel goes through.
pub fn table(&self) -> [u8; 256] {
let s = self.slope();
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
let mut t = [0u8; 256];
for (x, slot) in t.iter_mut().enumerate() {
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
}
t
}
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
/// trailing partial pixel is ignored.
pub fn apply_rgba(&self, rgba: &mut [u8]) {
if !self.changes_pixels() {
return;
}
let t = self.table();
for px in rgba.as_chunks_mut::<4>().0 {
for c in px.iter_mut().take(3) {
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
#[test]
fn matches_word() {
let cases: [(PicFx, [u8; 3]); 6] = [
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
];
for (fx, want) in cases {
let t = fx.table();
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
let got = t[x];
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
}
}
}
#[test]
fn channels_apart_alpha_kept() {
let fx = PicFx::new(80_000, -80_000, 0);
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
fx.apply_rgba(&mut px);
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
let mut odd = [0u8; 6];
fx.apply_rgba(&mut odd);
assert_eq!(odd[4..], [0, 0]);
}
#[test]
fn vml_mapping() {
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
let fx = PicFx::from_vml(g, b);
assert_eq!(fx, fx.clamped(), "{g} {b}");
let _ = fx.table();
}
}
#[test]
fn hostile_values_clamp() {
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
let fx = PicFx { bright: v, contrast: v, transparency: v };
let _ = fx.table();
assert!((0.0..=1.0).contains(&fx.opacity()));
let mut px = [1u8, 2, 3, 4];
fx.apply_rgba(&mut px);
assert_eq!(px[3], 4);
}
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
// Contrast 100 %: a threshold at mid-grey, no NaN.
let t = PicFx::new(0, FX_MAX, 0).table();
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
}
}
+3 -6
View File
@@ -23,15 +23,12 @@ mod para_props;
mod table_props;
pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
CellProps, HeightRule, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor,
};
#[cfg(test)]
+2 -41
View File
@@ -291,10 +291,6 @@ pub struct CharProps {
/// Kerning for fonts at or above this size (points); 0 = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub kern: Option<f32>,
/// A mark over (or under) each character (`w:em`, Font › Emphasis mark), as East Asian text
/// stresses words; `Some(EmphasisMark::None)` turns off one a style sets.
#[serde(skip_serializing_if = "Option::is_none")]
pub emphasis: Option<EmphasisMark>,
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
/// contextual ligatures.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -385,41 +381,6 @@ pub struct EastAsianLayout {
pub vert_compress: Option<bool>,
}
/// A mark over or under each character (`ST_Em`, ECMA-376 §17.18.24): Word's Font › Emphasis
/// mark.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum EmphasisMark {
#[default]
None,
/// A dot above.
Dot,
/// A comma (sesame) above.
Comma,
/// A hollow circle above.
Circle,
/// A dot below.
UnderDot,
}
impl EmphasisMark {
pub const ALL: [EmphasisMark; 5] = [EmphasisMark::None, EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot];
/// OOXML `w:em` value.
pub fn ooxml(self) -> &'static str {
match self {
EmphasisMark::None => "none",
EmphasisMark::Dot => "dot",
EmphasisMark::Comma => "comma",
EmphasisMark::Circle => "circle",
EmphasisMark::UnderDot => "underDot",
}
}
/// The mark an OOXML `w:em` value names; unknown values are none.
pub fn from_ooxml(v: &str) -> EmphasisMark {
EmphasisMark::ALL.into_iter().find(|m| m.ooxml() == v.trim()).unwrap_or_default()
}
}
/// Brackets around two lines in one (`ST_CombineBrackets`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -505,7 +466,7 @@ impl CharProps {
}
fn minus_run_visibility(&mut self, base: &CharProps) {
drop_same_fields!(self, base; web_hidden, snap_to_grid, emphasis);
drop_same_fields!(self, base; web_hidden, snap_to_grid);
}
fn minus_script_style(&mut self, base: &CharProps) {
@@ -536,7 +497,7 @@ impl CharProps {
}
fn overlay_run_visibility(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; web_hidden, snap_to_grid, emphasis);
overlay_fields!(self, patch; web_hidden, snap_to_grid);
}
fn overlay_script_style(&mut self, patch: &CharProps) {
+1 -74
View File
@@ -26,40 +26,6 @@ impl Align {
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlignment {
Auto,
Top,
Center,
Baseline,
Bottom,
}
impl TextAlignment {
pub fn ooxml(self) -> &'static str {
match self {
TextAlignment::Auto => "auto",
TextAlignment::Top => "top",
TextAlignment::Center => "center",
TextAlignment::Baseline => "baseline",
TextAlignment::Bottom => "bottom",
}
}
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
match s {
"auto" => Some(TextAlignment::Auto),
"top" => Some(TextAlignment::Top),
"center" => Some(TextAlignment::Center),
"baseline" => Some(TextAlignment::Baseline),
"bottom" => Some(TextAlignment::Bottom),
_ => None,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
@@ -141,18 +107,6 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -167,15 +121,6 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -188,15 +133,6 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -247,11 +183,6 @@ impl Borders {
pub fn any_visible(&self) -> bool {
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
}
/// A side is drawn, or a paragraph side without a line holds padding (ODF's, which keeps
/// its room in LibreOffice's layout).
pub fn takes_room(&self) -> bool {
self.any_visible() || [self.top, self.left, self.bottom, self.right].iter().flatten().any(|b| b.space.is_finite() && b.space > 0.0)
}
/// Apply every `Some` side of `patch` on top of `self`.
pub fn overlay(&mut self, patch: &Borders) {
if patch.top.is_some() {
@@ -432,10 +363,6 @@ pub struct ParaProps {
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_alignment: Option<TextAlignment>,
}
impl ParaProps {
@@ -456,7 +383,7 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
-38
View File
@@ -158,12 +158,6 @@ pub struct TableProps {
/// the right margin.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bidi_visual: bool,
/// Space above and below a table in the text flow, points (ODF's table `fo:margin-top` and
/// `fo:margin-bottom`; Word has none). Added to the spacing of the paragraphs around it.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
}
/// Where a floating table sits; the text after it wraps around it.
@@ -300,38 +294,6 @@ pub struct CellProps {
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
/// A picture behind the cell's content (an ODF cell's `style:background-image`; Word has
/// none).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<Box<CellPicture>>,
}
/// A cell's background picture: drawn at its own size where `align` puts it, tiled, or stretched
/// over the cell, and cut off at the cell's edges.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CellPicture {
/// Key into [`crate::Document::media`].
pub media: String,
/// Its own size, points.
pub size: [f32; 2],
pub repeat: PictureRepeat,
/// Where an unrepeated picture sits: 0 the left (top) edge, 0.5 the middle, 1 the right
/// (bottom) edge.
pub align: [f32; 2],
}
/// How a background picture fills its area (ODF §20.348 `style:repeat`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PictureRepeat {
/// Once, at its own size.
#[default]
None,
/// Repeated across the area.
Tile,
/// Stretched over the area.
Stretch,
}
impl CellProps {
-65
View File
@@ -41,9 +41,6 @@ pub struct ResolvedChar {
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
/// A mark over or under each character (Font › Emphasis mark).
#[serde(default)]
pub emphasis: crate::props::EmphasisMark,
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
/// contextual).
#[serde(default)]
@@ -100,7 +97,6 @@ impl ResolvedChar {
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
emphasis: c.emphasis.unwrap_or_default(),
ligatures: c.ligatures,
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
@@ -182,10 +178,6 @@ pub struct ResolvedPara {
pub tabs: Vec<TabStop>,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model
/// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it.
#[serde(default)]
pub padding: [f32; 4],
pub bidi: bool,
pub drop_cap: u8,
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
@@ -273,10 +265,6 @@ impl ResolvedPara {
tabs,
shading: p.shading,
borders: p.borders.filter(Borders::any_visible),
padding: p.borders.map_or([0.0; 4], |b| {
[b.top, b.left, b.bottom, b.right]
.map(|s| s.filter(|l| !l.is_visible() && l.space.is_finite() && l.space > 0.0).map_or(0.0, |l| l.space.min(1584.0)))
}),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
@@ -335,38 +323,6 @@ impl StyleSheet {
(pp, cp)
}
/// What a table style's paragraph formatting `table` changes for a paragraph with `p`, once
/// the paragraph style chain is applied over it (ECMA-376 §17.7.2): each property it gives
/// that the chain doesn't set. Formats without table styles state these directly.
pub fn table_para_patch(&self, p: &ParaProps, table: &ParaProps) -> ParaProps {
let style = p.style.clone().unwrap_or_else(|| "Normal".into());
let mut with = self.default_para.clone();
with.overlay(&ParaProps { style: None, ..table.clone() });
let (with, _) = self.para_style_props_over(&style, with, CharProps::default());
let (without, _) = self.para_style_props_over(&style, self.default_para.clone(), CharProps::default());
diff_patch(&with, &without)
}
/// What a table style's run formatting `table` changes for a run of character style
/// `run_style` in a paragraph of style `para_style`, once the paragraph and character style
/// chains are applied over it (ECMA-376 §17.7.2).
pub fn table_char_patch(&self, para_style: Option<&str>, run_style: Option<&str>, table: &CharProps) -> CharProps {
let chain = |defaults: CharProps| {
let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults);
if let Some(cs) = run_style {
for s in self.chain(cs) {
if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph {
cp.overlay(&s.chr);
}
}
}
cp
};
let mut with = self.default_chr.clone();
with.overlay(&CharProps { style: None, ..table.clone() });
diff_patch(&chain(with), &chain(self.default_chr.clone()))
}
/// Resolve a paragraph's properties.
pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara {
self.resolve_para_in(p, None)
@@ -431,16 +387,6 @@ impl StyleSheet {
}
}
/// The properties of `with` that differ from `without` (both fully resolved property sets of one
/// kind), as a set of only those; empty when serialisation fails.
fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, without: &T) -> T {
let (Ok(serde_json::Value::Object(w)), Ok(serde_json::Value::Object(o))) = (serde_json::to_value(with), serde_json::to_value(without)) else {
return T::default();
};
let patch: serde_json::Map<String, serde_json::Value> = w.into_iter().filter(|(k, v)| o.get(k) != Some(v)).collect();
serde_json::from_value(serde_json::Value::Object(patch)).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -578,15 +524,4 @@ mod tests {
assert_eq!(p.tabs.len(), 3);
assert_eq!(p.tabs[0].pos, 100.0);
}
/// Resolved formatting saved before paragraph padding existed still reads: no padding.
#[test]
fn resolved_para_without_padding_reads() {
let p = StyleSheet::builtin().resolve_para(&ParaProps::default());
let mut v = serde_json::to_value(&p).unwrap();
v.as_object_mut().unwrap().remove("padding");
let back: ResolvedPara = serde_json::from_value(v).unwrap();
assert_eq!(back.padding, [0.0; 4]);
assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p });
}
}
-19
View File
@@ -733,13 +733,6 @@ pub struct SectionProps {
/// Distance from the page edge to the header / footer.
pub header: f32,
pub footer: f32,
/// The space kept between a header and the body, and between the body and a footer, points
/// (ODF's header and footer spacing; Word keeps none). The margin already holds the band and
/// its spacing, so it only moves the body once content and spacing reach past the margin.
#[serde(skip_serializing_if = "is_zero")]
pub header_gap: f32,
#[serde(skip_serializing_if = "is_zero")]
pub footer_gap: f32,
pub gutter: f32,
/// Mirror margins (inside/outside) — set from the document settings.
pub columns: Columns,
@@ -779,11 +772,6 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub balance_columns: Option<bool>,
}
impl Default for SectionProps {
@@ -800,8 +788,6 @@ impl Default for SectionProps {
margin_right: 72.0,
header: 36.0,
footer: 36.0,
header_gap: 0.0,
footer_gap: 0.0,
gutter: 0.0,
columns: Columns::default(),
headers: HeaderSet::default(),
@@ -821,15 +807,10 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
balance_columns: None,
}
}
}
fn is_zero(v: &f32) -> bool {
*v == 0.0
}
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
!d.is_turned()
}
-22
View File
@@ -46,10 +46,6 @@ pub struct CustomProp {
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
/// Declared in an ODF document as a user field (§7.4.7 `text:user-field-decl`) rather than
/// a document property: written back as one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub odf_user_field: bool,
}
/// Document properties (File › Info).
@@ -166,23 +162,6 @@ pub struct Settings {
/// comments and tracked changes are saved without `w:date`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
/// it is laid out.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf: Option<OdfLayout>,
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
/// the file says about laying it out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
/// explicit stops are held from the margin either way; the default ones are counted from the
/// indent.
pub tabs_from_indent: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
@@ -328,7 +307,6 @@ impl Default for Settings {
zoom: None,
remove_personal_info: false,
remove_date_time: false,
odf: None,
}
}
}
+1 -35
View File
@@ -12,8 +12,6 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -200,14 +198,11 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level, color: None }
SmartArtItem { text: text.into(), level }
}
}
@@ -262,35 +257,6 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
-34
View File
@@ -232,40 +232,6 @@ impl PartSet {
}
}
/// A cell's borders from the table style's parts it is in, later parts winning. Row parts give
/// their left/right edges at the table's sides and `inside_v` between cells; column parts their
/// top/bottom edges at the table's top and bottom and `between` between rows; corner cells their
/// four edges.
pub fn part_borders(parts: &[(TablePart, Borders)], (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool)) -> Borders {
let mut out = Borders::default();
for (part, b) in parts {
let b = *b;
let mapped = if part.is_row() {
Borders {
top: b.top,
bottom: b.bottom,
left: if first_g { b.left } else { b.inside_v },
right: if last_g { b.right } else { b.inside_v },
between: None,
inside_v: None,
}
} else if part.is_column() {
Borders {
top: if ri == 0 { b.top } else { b.between },
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
left: b.left,
right: b.right,
between: None,
inside_v: None,
}
} else {
Borders { between: None, inside_v: None, ..b }
};
out.overlay(&mapped);
}
out
}
/// What one table style part gives its cells.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
+4 -6
View File
@@ -71,12 +71,10 @@ pub struct Table {
/// Hard limits that keep hostile input from exhausting memory.
pub const MAX_ROWS: usize = 32_767;
pub const MAX_COLS: usize = 63;
/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a
/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or
/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level
/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its
/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24.
pub const MAX_TABLE_DEPTH: usize = 18;
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
/// when it is saved, copied or dropped (#1596).
pub const MAX_TABLE_DEPTH: usize = 24;
impl Table {
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
+11 -12
View File
@@ -1,5 +1,4 @@
use super::*;
use std::sync::Arc;
mod note_conversion;
@@ -36,8 +35,8 @@ fn word_count_counts_each_chinese_and_japanese_character() {
#[test]
fn word_count_includes_numbered_list_labels_but_not_bullets() {
let mut d = Document::new();
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let bul_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
let num_id = d.numbering.add_list(ListKind::Numbered);
let bul_id = d.numbering.add_list(ListKind::Bullet);
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
@@ -61,9 +60,9 @@ fn word_count_includes_numbered_list_labels_but_not_bullets() {
#[test]
fn word_count_includes_numbered_labels_with_nothing_suffix() {
let mut d = Document::new();
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let num_id = d.numbering.add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
@@ -82,9 +81,9 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
#[test]
fn word_count_keeps_a_cjk_list_label_one_word() {
let mut d = Document::new();
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let num_id = d.numbering.add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
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;
@@ -188,7 +187,7 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() {
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
let n = Arc::make_mut(&mut d.numbering);
let n = &mut d.numbering;
n.add_list(ListKind::Bullet);
n.add_list(ListKind::Bullet);
n.abstracts[1].id = u32::MAX;
@@ -230,11 +229,11 @@ fn same_content_sees_every_change() {
assert!(!d.same_content(&e), "the formatting changed");
// The styles changed.
let mut e = d.clone();
Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
assert!(!d.same_content(&e), "a style changed");
// The lists changed.
let mut e = d.clone();
Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered);
e.numbering.add_list(ListKind::Numbered);
assert!(!d.same_content(&e), "a list was added");
// The settings changed.
let mut e = d.clone();
@@ -453,9 +452,9 @@ fn box_para(story: u32, id: u32) -> Blocks {
fn adopting_relinks_lists_in_deeply_nested_tables() {
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
let mut ours = Document::from_text("ours");
let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet);
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
let mut theirs = Document::from_text("theirs");
let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet);
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
assert_eq!(list, our_list);
let mut p = Paragraph::with_text("item", CharProps::default());
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
-28
View File
@@ -1,28 +0,0 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// The steps counted on this thread.
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests).
#[inline(always)]
pub(crate) fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
let _ = n;
}
/// The steps `f` takes.
#[cfg(any(test, feature = "test-support"))]
pub fn count(f: impl FnOnce()) -> u64 {
let before = STEPS.with(std::cell::Cell::get);
f();
STEPS.with(std::cell::Cell::get).saturating_sub(before)
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
let p = &sp.props;
if let Some(pib) = p.pib {
let media = pics.bse(pib, b.bstore, b.delay)?;
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None });
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
}
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
let mut kind = match sp.spt {
+7 -8
View File
@@ -109,13 +109,12 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
ICO.get(i as usize).copied()
}
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType; 0x02, thick, is in
/// the BRC of Microsoft's Word 97-2007 specification). Types the model doesn't carry (the gap
/// variants, 3-D, inset/outset) draw as single lines, as the DOCX reader maps them.
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model
/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX
/// reader maps them.
fn brc_style(t: u8) -> BorderStyle {
match t {
0x00 => BorderStyle::None,
0x02 => BorderStyle::Thick,
0x03 => BorderStyle::Double,
0x06 => BorderStyle::Dotted,
0x07 | 0x16 => BorderStyle::Dashed,
@@ -126,15 +125,15 @@ fn brc_style(t: u8) -> BorderStyle {
}
}
/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz`
/// reads (1-point steps for page borders, which never reach a paragraph property).
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
/// 1-point steps for page borders, which never reach a paragraph property).
fn brc_width(t: u8, dpt: u8) -> f32 {
if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) }
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
}
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
/// truncated operand is ignored.
pub(crate) fn brc80(op: &[u8]) -> Option<Border> {
fn brc80(op: &[u8]) -> Option<Border> {
let [dpt, ty, ico_index, tail, ..] = op else { return None };
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
return Some(Border::default());
+82 -6
View File
@@ -25,9 +25,7 @@ mod stsh;
mod table;
use std::io::{Read, Seek};
use std::sync::Arc;
use wordcraft_doc::link::hyperlink_target;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::para::Paragraph;
@@ -250,9 +248,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.core = core;
}
doc.body.clear();
doc.styles = Arc::new(fmt::stylesheet(&raw_styles, &fonts, standard_fonts));
doc.numbering = Arc::new(list::parse(&table, &fib, &fonts));
let sheet: StyleSheet = StyleSheet::clone(&doc.styles);
doc.styles = fmt::stylesheet(&raw_styles, &fonts, standard_fonts);
doc.numbering = list::parse(&table, &fib, &fonts);
let sheet: StyleSheet = doc.styles.clone();
let sects = sections::parse(&word, &table, &fib);
let stories = sections::header_stories(&table, &fib);
// Subdocument layout in the CP space: main document, footnotes, headers, comments,
@@ -623,7 +621,6 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
alt,
float: Default::default(),
crop: p.crop,
fx: Default::default(),
ole: None,
});
}
@@ -725,6 +722,85 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
/// (`#bookmark` for internal links); `None` when not a hyperlink.
fn hyperlink_target(instr: &str) -> Option<String> {
let t = instr.trim();
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim()
} else {
return None;
};
if rest.is_empty() {
return None;
}
let mut url: Option<String> = None;
let mut local: Option<String> = None;
let mut switch: Option<&str> = None;
let mut i = 0;
let bytes = rest.as_bytes();
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
if bytes[i] == b'\\' {
let start = i;
i += 1;
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
i += 1;
}
switch = Some(rest.get(start..i).unwrap_or(""));
continue;
}
let arg = if bytes[i] == b'"' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
let val = rest.get(start..i).unwrap_or("");
if i < bytes.len() && bytes[i] == b'"' {
i += 1;
}
val.to_string()
} else {
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
rest.get(start..i).unwrap_or("").to_string()
};
let clean_arg = arg.replace("%22", "\"");
match switch.take() {
Some("\\l") => local = Some(clean_arg),
Some(_) => {}
None => {
if url.is_none() {
url = Some(clean_arg);
}
}
}
}
match (url, local) {
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
let frag = l.trim_start_matches('#');
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
}
(Some(u), _) if !u.is_empty() => Some(u),
(None, Some(l)) if !l.is_empty() => {
let frag = l.trim_start_matches('#');
Some(format!("#{frag}"))
}
_ => None,
}
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
fn pic_location(chpx: &[u8]) -> Option<u32> {
for prl in sprm::iter(chpx) {
+23 -3
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use wordcraft_doc::Rgb;
use wordcraft_doc::para::Paragraph;
use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
use wordcraft_doc::table::{Cell, Row, Table};
use wordcraft_doc::{Block, Blocks, para_block};
@@ -458,12 +458,32 @@ fn def_table_edges(operand: &[u8]) -> Vec<f32> {
}
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
/// left, bottom, right, inside-horizontal, inside-vertical, read as paragraph borders are.
/// left, bottom, right, inside-horizontal, inside-vertical.
fn table_borders(operand: &[u8]) -> Option<Borders> {
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(fmt::brc80);
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80);
Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) })
}
/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte.
fn brc80(b: &[u8]) -> Option<Border> {
let [w, ty, ico, flags] = b else { return None };
let style = match ty {
0x00 => BorderStyle::None,
0x02 | 0x15 => BorderStyle::Thick,
0x03 => BorderStyle::Double,
0x04 | 0x05 => BorderStyle::Dotted,
0x06 | 0x07 => BorderStyle::Dashed,
0x08 | 0x09 => BorderStyle::DotDash,
0x0A | 0x18 => BorderStyle::Wave,
0x11 => BorderStyle::Triple,
_ => BorderStyle::Single,
};
if *ty == 0 && *w == 0 {
return None;
}
Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 })
}
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
+7 -71
View File
@@ -1209,53 +1209,6 @@ fn table_merges_borders_shading() {
assert_eq!(top.width, 0.5);
}
/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border.
#[test]
fn table_borders_read_border_types_as_paragraphs_do() {
use wordcraft_doc::props::BorderStyle as S;
let text = "a\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let row_end = {
let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat();
v.extend_from_slice(&0xD605u16.to_le_bytes());
v.push(0x18);
// Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero.
for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] {
v.extend_from_slice(&brc);
}
v
};
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
let b = tbl.props.borders.expect("table borders");
let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width)));
let want = [
(S::Dotted, true, 1.0),
(S::Triple, true, 1.0),
(S::Wave, true, 1.0),
(S::None, false, 0.0),
(S::Single, true, 12.0),
(S::None, false, 0.25),
];
assert_eq!(seen, want.map(Some));
// A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are.
let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph");
assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0));
}
// ---------------------------------------------------------------------------------------------
// D3: lists (PlfLst + PlfLfo) and the combined document.
@@ -1516,16 +1469,6 @@ fn hyperlink_target_needs_the_whole_field_name() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
}
/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX.
#[test]
fn hyperlink_target_keeps_the_address_after_a_bare_switch() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into()));
}
#[test]
fn bookmarks_become_objects() {
let text = "Hello world\r";
@@ -2539,21 +2482,17 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
}
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick
/// border (type 0x02) stays thick in both, as tables read it too.
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
#[test]
fn paragraph_borders_read_from_both_forms() {
use wordcraft_doc::props::BorderStyle as S;
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
let thick80 = [8u8, 0x02, 0x06, 0x01];
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00];
let text = "boxed\r\r";
let spec = StructSpec {
text,
papx_entries: vec![
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
(6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])),
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
(6, 0, grpprl(&[(0xC64E, &brc)])),
],
..structured_spec(text, text.chars().count() as u32)
};
@@ -2561,16 +2500,13 @@ fn paragraph_borders_read_from_both_forms() {
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
let b = p0.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0));
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick");
assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some());
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
let b = p1.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0));
let top = p1.props.borders.expect("borders").top.expect("top border");
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick");
}
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
-1
View File
@@ -391,7 +391,6 @@ fn a_floating_copy_is_charged_its_alt_text() {
alt,
float: Default::default(),
crop: Default::default(),
fx: Default::default(),
ole: None,
};
let bare = crate::object_cost(&image(String::new()));
+2 -14
View File
@@ -25,23 +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(),
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
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,
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
};
out.push(prop);
}
+32 -41
View File
@@ -5,8 +5,8 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
@@ -172,9 +172,8 @@ impl PropCtx {
}
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
/// scheme mapping, with its `tint` or else its `shade` (hex bytes) applied to the HSL
/// luminance as Word does: given both, Word applies the tint alone (MS-OI29500 17.3.2.6 b,
/// 17.4.32 a). When given it supersedes the fixed colour; `None` leaves the fixed one.
/// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as
/// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one.
fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option<Rgb> {
let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i);
let slot = match e.attr(attr)? {
@@ -200,7 +199,8 @@ impl PropCtx {
let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0);
if let Some(t) = byte(tint) {
c = adjust_lum(c, |l| l * t + (1.0 - t));
} else if let Some(s) = byte(shade) {
}
if let Some(s) = byte(shade) {
c = adjust_lum(c, |l| l * s);
}
Some(c)
@@ -208,10 +208,6 @@ impl PropCtx {
/// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour.
pub fn shd_fill(&self, e: &El) -> Option<Rgb> {
// `nil` counts as no shading, whatever fill it carries (MS-OI29500 17.18.78).
if e.attr("w:val") == Some("nil") {
return None;
}
if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") {
return Some(c);
}
@@ -254,7 +250,6 @@ impl PropCtx {
"w:cs" => c.cs = Some(on_off(k)),
"w:caps" => c.caps = Some(on_off(k)),
"w:smallCaps" => c.small_caps = Some(on_off(k)),
"w:em" => c.emphasis = Some(k.attr("w:val").map(wordcraft_doc::props::EmphasisMark::from_ooxml).unwrap_or_default()),
"w:strike" => c.strike = Some(on_off(k)),
"w:dstrike" => c.double_strike = Some(on_off(k)),
"w:outline" => c.outline = Some(on_off(k)),
@@ -287,7 +282,7 @@ impl PropCtx {
c.underline_color =
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
}
"w:bdr" => c.border = Some(self.border(k)),
"w:bdr" => c.border = Some(border(k)),
"w:shd" => c.shading = self.shd_fill(k),
"w:vertAlign" => {
c.vert_align = k.attr("w:val").map(|v| match v {
@@ -343,7 +338,6 @@ impl PropCtx {
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
"w:framePr" => {
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
@@ -367,7 +361,7 @@ impl PropCtx {
(None, None) => {}
}
}
"w:pBdr" => p.borders = Some(self.borders(k)),
"w:pBdr" => p.borders = Some(borders(k)),
"w:shd" => p.shading = self.shd_fill(k),
"w:tabs" => {
let tabs: Vec<TabStop> = k.children("w:tab").take(64).filter_map(tab_stop).collect();
@@ -482,32 +476,29 @@ fn shd_fill(e: &El) -> Option<Rgb> {
None
}
impl PropCtx {
/// One border (`CT_Border`); its theme colour supersedes `w:color`, as for text.
pub fn border(&self, e: &El) -> Border {
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
let color = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| e.attr("w:color").and_then(Rgb::parse));
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
Border { style, width, color, space }
}
pub fn border(e: &El) -> Border {
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
let color = e.attr("w:color").and_then(Rgb::parse);
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
Border { style, width, color, space }
}
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
pub fn borders(&self, e: &El) -> Borders {
let mut b = Borders::default();
for k in e.els() {
match k.local() {
"top" => b.top = Some(self.border(k)),
"left" | "start" => b.left = Some(self.border(k)),
"bottom" => b.bottom = Some(self.border(k)),
"right" | "end" => b.right = Some(self.border(k)),
"between" | "insideH" => b.between = Some(self.border(k)),
"insideV" => b.inside_v = Some(self.border(k)),
_ => {}
}
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
pub fn borders(e: &El) -> Borders {
let mut b = Borders::default();
for k in e.els() {
match k.local() {
"top" => b.top = Some(border(k)),
"left" | "start" => b.left = Some(border(k)),
"bottom" => b.bottom = Some(border(k)),
"right" | "end" => b.right = Some(border(k)),
"between" | "insideH" => b.between = Some(border(k)),
"insideV" => b.inside_v = Some(border(k)),
_ => {}
}
b
}
b
}
/// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right].
@@ -541,7 +532,7 @@ impl PropCtx {
}
"w:jc" => t.align = k.attr("w:val").map(align),
"w:tblInd" => t.indent = tw(k, "w:w"),
"w:tblBorders" => t.borders = Some(self.borders(k)),
"w:tblBorders" => t.borders = Some(borders(k)),
"w:shd" => t.shading = self.shd_fill(k),
"w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"),
"w:bidiVisual" => t.bidi_visual = on_off(k),
@@ -647,7 +638,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
"w:gridSpan" => c.span = k.attr("w:val").and_then(u32_of).unwrap_or(1).clamp(1, 63),
"w:hMerge" => hcont = k.attr("w:val") != Some("restart"),
"w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue },
"w:tcBorders" => c.borders = Some(pc.borders(k)),
"w:tcBorders" => c.borders = Some(borders(k)),
"w:shd" => c.shading = pc.shd_fill(k),
"w:noWrap" => c.no_wrap = on_off(k),
"w:hideMark" => c.hide_mark = on_off(k),
@@ -684,7 +675,7 @@ pub struct HfRef {
}
/// `w:sectPr` (header/footer references are returned for the caller to resolve).
pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
let mut s = SectionProps::default();
let mut refs = Vec::new();
for k in e.els() {
@@ -789,7 +780,7 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
_ => PageVAlign::Top,
}
}
"w:pgBorders" => s.page_borders = Some(pc.borders(k)),
"w:pgBorders" => s.page_borders = Some(borders(k)),
"w:docGrid" => {
let mut g = DocGrid::default();
if let Some(t) = k.attr("w:type").and_then(DocGridType::from_ooxml) {
+3 -3
View File
@@ -31,7 +31,7 @@ impl Reader<'_> {
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
if cap_font.is_some() && cap_font != text_font {
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
for r in &mut first.runs {
r.props.font = text_font.clone();
}
props.drop_cap_font = cap_font;
@@ -289,10 +289,10 @@ impl Reader<'_> {
}
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
let (mut sp, refs) = sectpr(&self.pc, s);
let (mut sp, refs) = sectpr(s);
// §17.13.5.32: the section's properties before a tracked change (no header references).
if let Some(ch) = s.child("w:sectPrChange") {
let old = ch.child("w:sectPr").map(|o| sectpr(&self.pc, o).0).unwrap_or_default();
let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default();
sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
}
for r in refs {
+1 -1
View File
@@ -263,7 +263,7 @@ impl Reader<'_> {
}
}
}
Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None })
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
}
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
+63 -1
View File
@@ -1,5 +1,4 @@
use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx};
use wordcraft_doc::link::hyperlink_target;
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
@@ -25,6 +24,69 @@ fn is_range_instr(instr: &str) -> bool {
crate::read::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
}
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
/// `#bookmark`. None for any other field, or one with no target.
fn hyperlink_target(instr: &str) -> Option<String> {
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let args = field_args(instr);
let mut args = args.iter();
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let (mut url, mut anchor) = (None, None);
while let Some((a, quoted)) = args.next() {
if *quoted || !a.starts_with('\\') {
url = url.or(Some(a.as_str()));
continue;
}
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
if a.eq_ignore_ascii_case("\\l") {
anchor = value.map(|(v, _)| v.as_str());
}
}
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
match (url, anchor) {
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
(Some(u), None) => Some(u.to_string()),
(None, Some(a)) => Some(format!("#{a}")),
(None, None) => None,
}
}
/// A field code's words: the field's name, its switches and its arguments, each with whether
/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash.
fn field_args(instr: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
let mut word = String::new();
if c == '"' {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()),
_ => word.push(c),
}
}
out.push((word, true));
} else {
word.push(c);
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
word.push(c);
}
out.push((word, false));
}
}
out
}
impl Reader<'_> {
// ---- fields ----
+1 -4
View File
@@ -134,10 +134,7 @@ impl Reader<'_> {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number);
let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0));
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None });
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
}
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
// the same picture: what the shape leaves out, it takes from there.
+6 -7
View File
@@ -2,7 +2,6 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
@@ -187,7 +186,7 @@ impl Reader<'_> {
if !sheet.styles.iter().any(|s| s.quick) {
sheet.add_default_gallery();
}
self.doc.styles = Arc::new(sheet);
self.doc.styles = sheet;
Ok(())
}
@@ -197,7 +196,7 @@ impl Reader<'_> {
let mut t = TableStyleParts::default();
if let Some(tp) = s.child("w:tblPr") {
if let Some(b) = tp.child("w:tblBorders") {
t.borders = Some(self.pc.borders(b));
t.borders = Some(props::borders(b));
}
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
@@ -211,7 +210,7 @@ impl Reader<'_> {
let f = PartFormat {
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
borders: tc.and_then(|p| p.child("w:tcBorders")).map(|b| self.pc.borders(b)),
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
align: ppr.as_ref().and_then(|p| p.align),
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
"center" => VAlign::Center,
@@ -230,7 +229,7 @@ impl Reader<'_> {
t.fill = f.fill;
}
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
t.borders = Some(self.pc.borders(b));
t.borders = Some(props::borders(b));
}
if f.v_align.is_some() {
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
@@ -299,8 +298,8 @@ impl Reader<'_> {
a.levels = levels;
}
}
Arc::make_mut(&mut self.doc.numbering).abstracts = abstracts;
Arc::make_mut(&mut self.doc.numbering).nums = nums;
self.doc.numbering.abstracts = abstracts;
self.doc.numbering.nums = nums;
Ok(())
}
+19 -83
View File
@@ -86,8 +86,6 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -152,22 +150,6 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -244,7 +226,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
}
out
}
@@ -265,14 +247,11 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
}
out
}
@@ -315,9 +294,6 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -325,7 +301,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -384,19 +360,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
out.push(Sh::line(px, pb, px, mid, accent));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
out.push(Sh::line(lo, mid, hi, mid, accent));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
out.push(Sh::line(x, mid, x, ct, accent));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
}
out
}
@@ -424,7 +400,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
out.push(sh.with_text(paras(spec, i, true)));
}
out
}
@@ -442,7 +418,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -456,14 +432,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -481,7 +457,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -502,22 +478,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, &i) in tops.iter().enumerate() {
for (j, _) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s.item(spec, i));
out.push(s);
}
// Every outline over every fill, so each circle shows whole.
for (j, &i) in tops.iter().enumerate() {
for (j, _) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s.item(spec, i));
out.push(s);
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -578,23 +554,6 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -784,16 +743,7 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.empty("dgm:spPr", &[]);
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1112,9 +1062,7 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
Some(SmartArtItem { text, level: (*l)? })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1190,18 +1138,6 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
-23
View File
@@ -84,29 +84,6 @@ pub fn int(s: &str) -> Option<i64> {
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
}
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
pub fn vml_number(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
let v = match t.strip_suffix('f') {
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
None => t.parse::<f64>().ok()?,
};
v.is_finite().then_some(v)
}
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
let lum = blip.child("a:lum");
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
}
pub fn u32_of(s: &str) -> Option<u32> {
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
}
+2 -15
View File
@@ -317,8 +317,7 @@ impl<'a> Conv<'a> {
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(blip) = drawing.find("a:blip") else { return Vec::new() };
let Some(id) = blip.attr("r:embed") else { return Vec::new() };
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
@@ -330,19 +329,7 @@ impl<'a> Conv<'a> {
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
let mut shape = self.shape_frame(&shape_id, &style, &src, &alt);
// Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711).
// `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped.
let fx = crate::units::blip_fx(blip);
if fx.changes_pixels()
&& let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata"))
{
let (gain, black) = fx.to_vml();
let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64);
data.attrs.push(("gain".into(), fixed(gain)));
data.attrs.push(("blacklevel".into(), fixed(black)));
}
kids.push(Node::El(shape));
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
vec![Node::El(plain("w:pict", kids))]
}
+2 -4
View File
@@ -294,10 +294,8 @@ fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels,
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
// VML has no centred fill (an ODT page background's): stretched to the page.
let kind = if p.fill_type.eq_ignore_ascii_case("center") { "frame" } else { p.fill_type.as_str() };
if !kind.is_empty() {
fa.push(("type", kind));
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
-7
View File
@@ -108,10 +108,6 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
}
toggle(w, "w:rtl", c.rtl);
toggle(w, "w:cs", c.cs);
// Emphasis mark (§17.3.2.12), after `w:cs` and before `w:lang`.
if let Some(m) = c.emphasis {
w.val("w:em", m.ooxml());
}
let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3);
if let Some(l) = &c.lang {
lang_attrs.push(("w:val", l));
@@ -287,9 +283,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
+2 -16
View File
@@ -15,7 +15,7 @@ pub(super) struct DrawingInfo<'a> {
impl Writer<'_> {
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, .. } = o else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
@@ -26,21 +26,7 @@ impl Writer<'_> {
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
// Brightness, contrast and transparency (#1711, #1766) as `a:blip` effects (ECMA-376
// §20.1.8.13 CT_Blip: an unbounded choice of effects, in any order).
let fx = fx.clamped();
if fx.is_identity() {
w.empty("a:blip", &[("r:embed", &rid)]);
} else {
w.open("a:blip", &[("r:embed", &rid)]);
if fx.transparency != 0 {
w.empty("a:alphaModFix", &[("amt", &n(i64::from(100_000 - fx.transparency)))]);
}
if fx.bright != 0 || fx.contrast != 0 {
w.empty("a:lum", &[("bright", &n(i64::from(fx.bright))), ("contrast", &n(i64::from(fx.contrast)))]);
}
w.close("a:blip");
}
w.empty("a:blip", &[("r:embed", &rid)]);
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
+1 -1
View File
@@ -249,7 +249,7 @@ impl Writer<'_> {
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref().filter(|e| !e.is_odf_object())
if let Some(src) = ole.as_deref()
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
+1 -10
View File
@@ -317,16 +317,7 @@ fn a_chunks_pictures_come_with_it() {
let mut d = Document::new();
let key = d.add_media(vec![1, 2, 3], "png");
let mut para = wordcraft_doc::Paragraph::new();
let image = InlineObject::Image {
media: key,
w: 10.0,
h: 10.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
para.insert_object(0, image, &Default::default()).unwrap();
d.body = vec![std::sync::Arc::new(Block::Para(para))];
Some(d)
+1 -2
View File
@@ -7,7 +7,6 @@
//! in a right-to-left paragraph.
use std::io::Read;
use std::sync::Arc;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, para_block};
@@ -190,6 +189,6 @@ fn a_language_the_run_already_has_is_kept() {
#[test]
fn an_inherited_bidi_language_is_not_repeated() {
let mut d = doc_of("سلام WordCraft دنیا", true, arial());
Arc::make_mut(&mut d.styles).default_chr.lang_bidi = Some("fa-IR".into());
d.styles.default_chr.lang_bidi = Some("fa-IR".into());
assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]);
}
-76
View File
@@ -1,76 +0,0 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
+4 -5
View File
@@ -2,7 +2,6 @@
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
//! the reader reads an omitted size as 10 pt.
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::Document;
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
fn resolved(d: &Document) -> f32 {
@@ -20,7 +19,7 @@ fn defaults(xml: &str) -> &str {
#[test]
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
let mut d = Document::new();
Arc::make_mut(&mut d.styles).default_chr.size = None;
d.styles.default_chr.size = None;
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
@@ -34,7 +33,7 @@ fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
fn an_explicit_default_size_is_written_as_it_is() {
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
let mut d = Document::new();
Arc::make_mut(&mut d.styles).default_chr.size = Some(size);
d.styles.default_chr.size = Some(size);
let bytes = wordcraft_docx::write(&d).unwrap();
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
}
@@ -60,8 +59,8 @@ fn an_unset_default_size_does_not_take_a_styles_size() {
// The written default is the resolver's own fallback, not what the Normal style resolves to,
// so a style that sets a size keeps it and one that does not still falls back to the default.
let mut d = Document::new();
Arc::make_mut(&mut d.styles).default_chr.size = None;
if let Some(normal) = Arc::make_mut(&mut d.styles).get_mut("Normal") {
d.styles.default_chr.size = None;
if let Some(normal) = d.styles.get_mut("Normal") {
normal.chr.size = Some(14.0);
}
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
-1
View File
@@ -22,7 +22,6 @@ fn picture() -> InlineObject {
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
-48
View File
@@ -1,48 +0,0 @@
//! Emphasis marks (`w:em`, ISO/IEC 29500-1 §17.3.2.12: Font › Emphasis mark) are read and
//! saved again, in their place in `w:rPr`. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::EmphasisMark;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, xml) in [("_rels/.rels", root.to_string()), ("word/document.xml", doc)] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(xml.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn marks(d: &wordcraft_doc::Document) -> Vec<Option<EmphasisMark>> {
d.body[0].as_para().unwrap().runs.iter().map(|r| r.props.emphasis).collect()
}
#[test]
fn emphasis_marks_are_read_and_saved() {
let run = |t: &str, em: &str| format!(r#"<w:r><w:rPr><w:cs/>{em}<w:lang w:val="ja-JP"/></w:rPr><w:t>{t}</w:t></w:r>"#);
let body = format!(
"<w:p>{}{}{}{}{}{}</w:p>",
run("a", r#"<w:em w:val="dot"/>"#),
run("b", r#"<w:em w:val="comma"/>"#),
run("c", r#"<w:em w:val="circle"/>"#),
run("d", r#"<w:em w:val="underDot"/>"#),
run("e", r#"<w:em w:val="none"/>"#),
run("f", r#"<w:em w:val="sparkle"/>"#),
);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
// An unknown mark is none (and the run with it joins the one before, now alike).
let want = [EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot, EmphasisMark::None].map(Some);
assert_eq!(marks(&d), want);
let saved = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
// In its place: after w:cs, before w:lang.
assert!(xml.contains(r#"<w:cs/><w:em w:val="underDot"/><w:lang"#), "{xml}");
assert_eq!(marks(&wordcraft_docx::read(&saved).unwrap()), want);
}
+1 -216
View File
@@ -1,7 +1,6 @@
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
use std::io::Write;
use std::sync::Arc;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
@@ -253,7 +252,7 @@ fn omitted_paragraph_defaults_can_be_edited() {
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert!(!d.styles.default_para_present);
Arc::make_mut(&mut d.styles).default_para.space_after = Some(12.0);
d.styles.default_para.space_after = Some(12.0);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(again.styles.default_para_present);
assert_eq!(again.styles.default_para.space_after, Some(12.0));
@@ -2170,68 +2169,6 @@ fn theme_colour_wins_over_the_fixed_colour() {
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
}
/// `w:shd w:val="nil"` paints nothing despite its fill: a CV template's red cell showed over the name.
#[test]
fn nil_shading_paints_no_fill() {
let body = r#"
<w:tbl><w:tblGrid><w:gridCol w:w="200"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr>
<w:tc><w:tcPr><w:shd w:val="nil" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc>
<w:tc><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
<w:p><w:pPr><w:shd w:val="nil" w:fill="C00000" w:themeFill="accent2"/></w:pPr><w:r><w:rPr><w:shd w:val="nil" w:color="C00000" w:fill="C00000"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
let d = read_body(body);
let Block::Table(t) = &*d.body[0] else { panic!("table first") };
assert_eq!(t.rows[0].cells[0].props.shading, None);
assert_eq!(t.rows[0].cells[1].props.shading, Some(Rgb(0xC0, 0, 0)));
let p = paras(&d);
assert_eq!(p[0].props.shading, None);
assert_eq!(p[0].props_of_char(0).shading, None);
}
/// A border's theme colour wins over its stale `w:color`: a template's white rule showed as black.
#[test]
fn border_theme_colour_wins_over_the_fixed_colour() {
let body = r#"
<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="background1"/><w:left w:val="single" w:sz="4" w:color="FF0000"/></w:pBdr></w:pPr><w:r><w:rPr><w:bdr w:val="single" w:sz="4" w:color="auto" w:themeColor="accent2"/></w:rPr><w:t>x</w:t></w:r></w:p>
<w:tbl><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="accent1"/></w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcBorders><w:left w:val="single" w:sz="4" w:color="000000" w:themeColor="accent6" w:themeShade="BF"/></w:tcBorders></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
<w:p/>
<w:sectPr><w:pgBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="text2"/></w:pgBorders></w:sectPr>"#;
let styles = r#"<w:style w:type="table" w:styleId="T"><w:name w:val="T"/><w:tblPr><w:tblBorders><w:bottom w:val="single" w:sz="4" w:color="000000" w:themeColor="accent3"/></w:tblBorders></w:tblPr></w:style>"#;
let d = themed_docx(body, styles, None);
let p = paras(&d);
let pb = p[0].props.borders.unwrap();
assert_eq!(pb.top.and_then(|b| b.color), Some(Rgb(0xFF, 0xFF, 0xFF)));
assert_eq!(pb.left.and_then(|b| b.color), Some(Rgb(0xFF, 0, 0)), "no theme colour keeps the fixed one");
assert_eq!(p[0].props_of_char(0).border.and_then(|b| b.color), Some(Rgb(0xED, 0x7D, 0x31)));
let Block::Table(t) = &*d.body[1] else { panic!("table second") };
assert_eq!(t.props.borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x72, 0xC4)));
let cell = t.rows[0].cells[0].props.borders.and_then(|b| b.left).and_then(|b| b.color);
assert!(near(cell.map(TextColor::Rgb), Rgb(0x53, 0x81, 0x35)), "{cell:?}");
assert_eq!(d.last_section.page_borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x54, 0x6A)));
let st = d.styles.get("T").and_then(|s| s.table.as_ref()).and_then(|t| t.borders);
assert_eq!(st.and_then(|b| b.bottom).and_then(|b| b.color), Some(Rgb(0xA5, 0xA5, 0xA5)));
}
/// Given both `themeTint` and `themeShade`, Word applies the tint alone, for text colours,
/// shading and borders (MS-OI29500 17.3.2.6 b, 17.4.32 a; MS-OE376 2.3.2.3 a).
#[test]
fn theme_tint_wins_over_theme_shade() {
let both = r#"w:themeColor="accent1" w:themeTint="99" w:themeShade="7F""#;
let body = format!(
r#"<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" {both}/></w:pBdr></w:pPr><w:r><w:rPr><w:color w:val="000000" {both}/><w:bdr w:val="single" w:sz="4" w:color="000000" {both}/><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent1" w:themeFillTint="99" w:themeFillShade="7F"/></w:rPr><w:t>a</w:t></w:r></w:p>"#
);
let d = themed_docx(&body, "", None);
let p = paras(&d);
let ch = p[0].props_of_char(0);
// accent1 4472C4 lightened by tint 99 alone, as Word writes it into w:val.
let tinted = Rgb(0x8E, 0xAA, 0xDB);
assert!(near(ch.color, tinted), "text colour {:?}", ch.color);
let run_border = ch.border.and_then(|b| b.color).map(TextColor::Rgb);
assert!(near(run_border, tinted), "text border {run_border:?}");
let para_border = p[0].props.borders.and_then(|b| b.top).and_then(|b| b.color).map(TextColor::Rgb);
assert!(near(para_border, tinted), "paragraph border {para_border:?}");
assert!(near(ch.shading.map(TextColor::Rgb), tinted), "shading {:?}", ch.shading);
}
#[test]
fn theme_colours_follow_the_colour_scheme_mapping() {
// Styles are read before settings; the mapping still applies to them.
@@ -3941,144 +3878,6 @@ fn hyperlink_with_empty_target_is_no_link() {
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
}
/// Picture brightness, contrast and transparency (#1711, #1766): VML `gain`/`blacklevel` (Word's
/// watermark "Washout"), DrawingML `a:lum` and `a:alphaModFix`; saved as DrawingML and read back.
#[test]
fn picture_brightness_contrast_transparency() {
use wordcraft_doc::picfx::PicFx;
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
let vml =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape style="width:1in;height:36pt"><v:imagedata r:id="rIdImg" {attrs}/></v:shape></w:pict></w:r>"#);
let dml = |blip: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="3" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg">{blip}</a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let cases: Vec<(String, Option<PicFx>)> = vec![
// Word's Washout: 0.3 and 0.35 in 16.16 fixed point.
(vml(r#"gain="19661f" blacklevel="22938f""#), Some(PicFx::new(70_000, -70_000, 0))),
(vml(r#"gain="0.5" blacklevel="0.25""#), Some(PicFx::new(50_000, -50_000, 0))),
(vml(r#"gain="131072f""#), Some(PicFx::new(0, 50_000, 0))),
(vml(""), Some(PicFx::default())),
(dml(r#"<a:lum bright="80000" contrast="-80000"/>"#), Some(PicFx::new(80_000, -80_000, 0))),
(dml(r#"<a:alphaModFix amt="40000"/><a:lum bright="-20000"/>"#), Some(PicFx::new(-20_000, 0, 60_000))),
(dml(""), Some(PicFx::default())),
// Hostile values: clamped, never a panic.
(vml(r#"gain="NaNf" blacklevel="infinity""#), None),
(vml(r#"gain="-99999999f" blacklevel="99999999999f""#), None),
(vml(r#"gain="1e308" blacklevel="-1e308""#), None),
(vml(r#"gain="f" blacklevel="--3""#), None),
(dml(r#"<a:lum bright="999999999" contrast="-99999999999999999999"/><a:alphaModFix amt="-5"/>"#), None),
(dml(r#"<a:alphaModFix amt="250000"/><a:lum bright="x" contrast=""/>"#), None),
];
let body: String = cases.iter().map(|(x, _)| format!("<w:p>{x}</w:p>")).collect();
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(&[("rIdImg", "image", "media/pic.png")]);
let bytes = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", r.as_bytes()),
("word/media/pic.png", png),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
let fx_of = |d: &Document| -> Vec<PicFx> {
paras(d)
.iter()
.map(|p| match p.objects.first() {
Some(InlineObject::Image { fx, .. }) => *fx,
o => panic!("{o:?}"),
})
.collect()
};
let read = fx_of(&d);
assert_eq!(read.len(), cases.len());
for ((x, want), got) in cases.iter().zip(&read) {
assert_eq!(*got, got.clamped(), "{x}");
if let Some(want) = want {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(got.bright, want.bright) && near(got.contrast, want.contrast) && got.transparency == want.transparency, "{x}: {got:?}");
}
}
// Clamped hostile values.
assert_eq!(read[11], PicFx::new(100_000, -100_000, 100_000));
assert_eq!(read[12], PicFx::default());
// Saved as DrawingML (VML pictures too) and read back unchanged.
let out = wordcraft_docx::write(&d).unwrap();
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(fx_of(&back), read);
}
/// Word's picture watermark with Washout (#1711): a VML picture in a header's Watermarks
/// building block keeps its brightness and contrast.
#[test]
fn washout_picture_watermark_in_header() {
let hdr = format!(
r##"<w:hdr {W_NS}><w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Watermarks"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:200pt;height:200pt;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"><v:imagedata r:id="rImg" o:title="wm" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p></w:sdtContent></w:sdt></w:hdr>"##
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let id = d.last_section.headers.default.unwrap();
let fx = d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.expect("watermark picture");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
/// A faded DrawingML picture behind a header's text (#1766; #1961 review): `a:alphaModFix` reads
/// and is written back once, as in the body.
#[test]
fn faded_picture_behind_text_in_header() {
let hdr = format!(
r#"<w:hdr {W_NS}><w:p><w:r><w:drawing><wp:anchor behindDoc="1" distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>center</wp:align></wp:positionV><wp:extent cx="2540000" cy="2540000"/><wp:wrapNone/><wp:docPr id="7" name="Faded"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rImg"><a:alphaModFix amt="20000"/></a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r></w:p></w:hdr>"#
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let header_picture = |d: &wordcraft_doc::Document| {
let id = d.last_section.headers.default.unwrap();
d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, float, .. } = o { Some((*fx, float.wrap)) } else { None })
.expect("header picture")
};
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let (fx, wrap) = header_picture(&d);
assert_eq!(fx, wordcraft_doc::picfx::PicFx::new(0, 0, 80_000));
assert_eq!(wrap, wordcraft_doc::para::Wrap::BehindText);
let saved = wordcraft_docx::write(&d).unwrap();
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(header_picture(&again), (fx, wrap), "the same picture after saving");
}
/// An OLE object's preview keeps the corrections of its picture (#1961 review).
#[test]
fn ole_preview_keeps_its_corrections() {
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rIdImg" gain="19661f" blacklevel="22938f"/></v:shape></w:object></w:r></w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[("rIdImg", "image", "media/p.png")], &[("word/media/p.png", "not really a png")])).unwrap();
let fx = d.body[0]
.as_para()
.and_then(|p| p.objects.iter().find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }))
.expect("the preview");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
#[test]
fn row_table_borders_round_trip() {
let d = read_body(
@@ -4514,20 +4313,6 @@ fn vml_textbox_style_anchor_and_fit() {
assert!(tb.body.auto_fit);
}
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
/// the runs in that font get the text's font back, the other letter keeps its own.
#[test]
fn drop_cap_frame_keeps_other_fonts() {
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
}
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
+1 -2
View File
@@ -4,7 +4,6 @@
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block};
@@ -192,7 +191,7 @@ fn glossary_entries_are_read_into_the_model() {
fn edited_glossary_is_written_from_the_model() {
let mut doc = wordcraft_docx::read(&with_glossary()).unwrap();
doc.glossary[0].name = "Sign-off".into();
let num = Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
let mut listed = Paragraph::with_text("Second line", CharProps::default());
listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
doc.glossary.push(BuildingBlock {
-3
View File
@@ -1,7 +1,6 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -14,7 +13,6 @@ mod default_size;
mod drawing_payload;
mod east_asian_lang;
mod east_asian_layout;
mod emphasis_marks;
mod fixtures;
mod glossary;
mod heading_levels;
@@ -33,7 +31,6 @@ mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod undefined_style;
+2 -3
View File
@@ -5,7 +5,6 @@
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::sync::Arc;
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
@@ -273,7 +272,7 @@ fn comment_references_use_the_documents_annotation_reference_style() {
assert!(!body.contains(r#"w:val="CommentReference""#), "{body}");
// A document without that style keeps the English id.
let mut doc = wordcraft_doc::Document::new();
Arc::make_mut(&mut doc.styles).styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap();
doc.body = rebuilt.body.clone();
doc.parts = rebuilt.parts.clone();
@@ -315,7 +314,7 @@ fn list_identities_survive_and_a_copied_definition_gets_none() {
// A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid.
let mut copy = doc.numbering.abstracts[0].clone();
copy.id = 7;
Arc::make_mut(&mut doc.numbering).abstracts.push(copy);
doc.numbering.abstracts.push(copy);
let n = text(&save(&doc), "word/numbering.xml");
assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}");
}
+16 -52
View File
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -95,7 +95,6 @@ fn every_char_prop_round_trips() {
scale: Some(150.0),
position: Some(3.0),
kern: Some(8.0),
emphasis: Some(wordcraft_doc::props::EmphasisMark::Comma),
ligatures: Some(wordcraft_doc::props::Ligatures { standard: true, historical: true, ..Default::default() }),
outline: Some(true),
shadow: Some(true),
@@ -221,7 +220,6 @@ fn every_para_prop_round_trips() {
num_change: None,
fmt_change: None,
snap_to_grid: Some(false),
text_alignment: Some(TextAlignment::Top),
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
@@ -250,7 +248,7 @@ fn every_para_prop_round_trips() {
#[test]
fn styles_and_lists_round_trip() {
let mut d = Document::new();
Arc::make_mut(&mut d.styles).upsert(Style {
d.styles.upsert(Style {
id: "MyStyle".into(),
name: "My Style".into(),
kind: StyleKind::Paragraph,
@@ -267,7 +265,7 @@ fn styles_and_lists_round_trip() {
default: false,
table: None,
});
Arc::make_mut(&mut d.styles).upsert(Style {
d.styles.upsert(Style {
id: "MyStyleChar".into(),
name: "My Style Char".into(),
kind: StyleKind::Character,
@@ -276,10 +274,10 @@ fn styles_and_lists_round_trip() {
hidden: true,
..Default::default()
});
let bullets = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
let nums = Arc::make_mut(&mut d.numbering).add_list(ListKind::Legal);
let outline = Arc::make_mut(&mut d.numbering).add_list(ListKind::Outline);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == outline) {
let bullets = d.numbering.add_list(ListKind::Bullet);
let nums = d.numbering.add_list(ListKind::Legal);
let outline = d.numbering.add_list(ListKind::Outline);
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == outline) {
n.start_overrides.push((0, 4));
}
let mut ps = Vec::new();
@@ -429,7 +427,6 @@ fn images_round_trip() {
// Room for a shadow: kept through save and load.
float: Float { effect: [12.0, 12.0, 27.0, 27.0], ..Default::default() },
crop: [0.1, 0.0, 0.25, 0.05],
fx: Default::default(),
ole: None,
};
let floating = InlineObject::Image {
@@ -439,7 +436,6 @@ fn images_round_trip() {
alt: String::new(),
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 36.0, y: 12.5, dist: 9.0, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let aligned = InlineObject::Image {
@@ -459,7 +455,6 @@ fn images_round_trip() {
..Default::default()
},
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let mut floats = vec![floating.clone(), aligned];
@@ -471,7 +466,6 @@ fn images_round_trip() {
alt: String::new(),
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 0.0, y: 0.0, dist: 0.0, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
});
}
@@ -573,7 +567,7 @@ fn table_style_formatting_round_trips() {
band_size: Some(3),
..Default::default()
};
Arc::make_mut(&mut d.styles).upsert(Style {
d.styles.upsert(Style {
id: "Whole".into(),
name: "Whole".into(),
kind: StyleKind::Table,
@@ -626,7 +620,7 @@ fn every_table_style_part_round_trips() {
};
st.set_table_part(part, f);
}
Arc::make_mut(&mut d.styles).upsert(st.clone());
d.styles.upsert(st.clone());
let r = rt(&d);
let got = r.styles.get("Parts").unwrap();
for part in TablePart::ALL {
@@ -1194,16 +1188,7 @@ fn groups_round_trip() {
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
let pic = InlineObject::Image {
media: key,
w: 60.0,
h: 40.0,
alt: "A picture".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let oval = InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 50.0,
@@ -1290,16 +1275,7 @@ fn drawing_canvas_round_trips() {
use wordcraft_doc::para::{CanvasStyle, GroupChild};
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let pic = InlineObject::Image {
media: key,
w: 60.0,
h: 40.0,
alt: "In the canvas".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "In the canvas".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let star = InlineObject::Shape {
kind: ShapeKind::Star,
w: 72.0,
@@ -1732,7 +1708,7 @@ fn kitchen_sink() -> Document {
let mut d = Document::new();
let png = tiny_png();
let key = d.add_media(png, "png");
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let num = d.numbering.add_list(ListKind::Numbered);
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
@@ -1757,16 +1733,7 @@ fn kitchen_sink() -> Document {
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image {
media: key,
w: 30.0,
h: 20.0,
alt: "pic".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
@@ -1861,7 +1828,6 @@ fn hostile_model_values_still_write() {
alt: String::new(),
float: Float::default(),
crop: [f32::NAN; 4],
fx: Default::default(),
ole: None,
},
&CharProps::default(),
@@ -1974,10 +1940,10 @@ fn list_level_overrides_round_trip() {
use wordcraft_doc::numbering::{Counters, Level};
use wordcraft_doc::section::NumFormat;
let mut d = Document::new();
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let restart = Arc::make_mut(&mut d.numbering).restart(num).unwrap();
let num = d.numbering.add_list(ListKind::Numbered);
let restart = d.numbering.restart(num).unwrap();
let own = Level { format: NumFormat::DecimalZero, text: "%1.%2".into(), indent: 26.5, hanging: 26.5, ..Level::default() };
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == restart) {
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == restart) {
n.level_overrides = vec![(1, own.clone())];
}
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
@@ -2132,7 +2098,6 @@ fn rotation_and_flips_round_trip() {
alt: String::new(),
float: Float { rot: 30.0, effect: [6.0; 4], ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let shape = |kind, float| InlineObject::Shape {
@@ -2633,7 +2598,6 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
alt: "A kiln at dusk".into(),
float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let shape = |alt: AltText| InlineObject::Shape {
-64
View File
@@ -1,64 +0,0 @@
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
//! moves every line after. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextAlignment;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn text_alignment_is_written_back_as_read() {
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body = [
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
para("", "G"),
]
.concat();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
let want = vec![
Some(TextAlignment::Top),
Some(TextAlignment::Center),
Some(TextAlignment::Baseline),
Some(TextAlignment::Bottom),
Some(TextAlignment::Auto),
None,
None,
];
assert_eq!(got, want);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
for v in ["center", "baseline", "bottom", "auto"] {
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
}
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
}
-1
View File
@@ -215,7 +215,6 @@ fn picture_in_box_keeps_its_frame() {
alt: "Synthetic marker".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let mut p = Paragraph::new();
+1 -11
View File
@@ -106,7 +106,7 @@ fn wordml_2003_sample() -> String {
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
<w:sectPr>
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
@@ -117,16 +117,6 @@ fn wordml_2003_sample() -> String {
)
}
/// Word 2003 XML pictures keep Word's Washout (gain 0.3, black level 0.35) as brightness and
/// contrast (#1711).
#[test]
fn word_2003_xml_picture_keeps_washout() {
let doc = read_xml(wordml_2003_sample().as_bytes()).unwrap();
let ps = paras(&doc);
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
assert!(matches!(pic, Some(InlineObject::Image { fx, .. }) if (fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2), "{pic:?}");
}
#[test]
fn imports_word_2003_xml() {
let xml = wordml_2003_sample();
+2 -53
View File
@@ -32,7 +32,7 @@ fn sample() -> Document {
d.core.title = "Garden <notes> & more".into();
d.core.creator = "Ada".into();
let key = d.add_media(tiny_png(), "png");
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let num = d.numbering.add_list(ListKind::Numbered);
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
@@ -69,16 +69,7 @@ fn sample() -> Document {
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image {
media: key,
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
@@ -189,48 +180,6 @@ fn big_and_deep_documents_write() {
}
}
/// Picture brightness and contrast (#1711) survive a Word 2003 XML save as VML `gain` and
/// `blacklevel`; transparency has no place on `v:imagedata` and is dropped.
#[test]
fn picture_brightness_and_contrast_round_trip() {
use wordcraft_doc::picfx::PicFx;
let cases = [PicFx::new(70_000, -70_000, 0), PicFx::new(-30_000, 50_000, 40_000), PicFx::default()];
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
d.body = cases
.iter()
.map(|fx| {
let mut p = Paragraph::new();
let pic = InlineObject::Image {
media: key.clone(),
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: *fx,
ole: None,
};
p.insert_object(0, pic, &CharProps::default()).unwrap();
para_block(p)
})
.collect();
let xml = write_wordml2003(&d).expect("write");
let text = String::from_utf8_lossy(&xml);
assert!(text.contains(r#"gain="19661f""#) && text.contains(r#"blacklevel="22938f""#), "Word's Washout values");
let back = read_xml(&xml).expect("read");
let got: Vec<PicFx> = paras(&back)
.iter()
.flat_map(|p| p.objects.iter())
.filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.collect();
assert_eq!(got.len(), cases.len());
for (g, want) in got.iter().zip(cases) {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}");
}
}
#[test]
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
-2
View File
@@ -44,8 +44,6 @@ getrandom = { workspace = true }
# Language packs (CP4.3): downloaded, verified and unpacked by the suite's font store.
craft-font-store = { workspace = true }
craft-font-catalog = { workspace = true }
# The local time zone's offset for Insert ▸ Date & Time and date fields (#1707); the web asks the browser's Date.
chrono = { workspace = true, features = ["clock"] }
[target.'cfg(unix)'.dependencies]
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
-1
View File
@@ -70,7 +70,6 @@ Home|Font|Character Shading|format.shading
Home|Font|Font Dialog|format.fontDialog
Home|Font|Double Strikethrough|format.doubleStrikethrough
Home|Font|Small Caps|format.smallCaps
Home|Font|Emphasis Mark|format.emphasisMark
Home|Font|All Caps|format.allCaps
Home|Font|Hidden|format.hidden
Home|Font|Emboss|format.emboss
-53
View File
@@ -548,59 +548,6 @@ pub fn now_iso() -> String {
iso_from_unix_secs(now_unix())
}
/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving
/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found.
pub fn local_offset_secs(unix: u64) -> i64 {
system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600)
}
#[cfg(not(target_arch = "wasm32"))]
fn system_offset_secs(unix: u64) -> i64 {
use chrono::Offset;
let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else {
return 0;
};
// chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so
// fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's
// panic hook (logged, no abort); the document is untouched.
std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0)
}
/// On the web the browser's `Date` gives the offset directly (chrono isn't used there).
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
fn system_offset_secs(unix: u64) -> i64 {
let ms = (unix as f64) * 1000.0;
if !ms.is_finite() {
return 0;
}
// `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date.
let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset();
if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 }
}
/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC.
#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))]
fn system_offset_secs(_unix: u64) -> i64 {
0
}
/// The local civil date and time now: see [`local_civil`].
pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) {
let now = now_unix();
local_civil(now, local_offset_secs(now))
}
/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since
/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from
/// UTC (east positive). Saturating, so a hostile offset or clock never overflows.
pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) {
let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs);
let days = local.div_euclid(86_400);
let t = local.rem_euclid(86_400) as u32;
let (y, m, d) = civil_from_days(days);
(y, m, d, (t / 3600, t / 60 % 60, t % 60), days)
}
/// Days since 1970-01-01 → (year, month, day).
///
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
+2 -5
View File
@@ -194,15 +194,12 @@ fn edit(s: &mut Session, f: impl Fn(&mut ChartSpec) -> Result<(), CmdError>) ->
Ok(out)
}
/// Draw the chart of `o` again at its current size (after a resize, or when it opens), if it
/// has a model. It keeps the chart as read (its `source`): the chart itself is unchanged, and
/// saving writes that back with the look WordCraft doesn't model.
/// Draw the chart of `o` again at its current size (after a resize), if it has a model.
pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) {
if let InlineObject::Graphic { w, h, graphic, .. } = o
&& let Some(spec) = graphic.chart.as_deref()
{
let source = graphic.source.clone();
*graphic = Arc::new(Graphic { source, ..chart_graphic(spec.clone(), theme, *w, *h) });
*graphic = Arc::new(chart_graphic(spec.clone(), theme, *w, *h));
}
}
+5 -231
View File
@@ -6,7 +6,6 @@ use super::{
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
};
use std::sync::Arc;
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
@@ -66,60 +65,14 @@ fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option
update_generated(s)
}
/// Most paragraphs a list held as a field around its entries spans.
const MAX_WRAPPED: usize = 10_000;
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
/// field that starts at body block `i` and runs over its entries: its field code and the block
/// it ends in. `None` for anything else (a TOC has its own update).
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)> {
let p = s.doc.body.get(i)?.as_para()?;
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
InlineObject::FieldStart { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some())
.then(|| (k, off, t.to_string()))
}
_ => None,
})?;
// Only when it starts the paragraph: text before it is the document's, not the list's.
if !p.text.get(..off).is_some_and(|t| t.chars().all(|c| c.is_whitespace() || c == wordcraft_doc::para::OBJ)) {
return None;
}
// Where it ends: the field end that closes it, nested fields counted from its own start (a
// field before it in its paragraph, an empty `PAGE`…, closes before it and isn't it).
let mut depth = 0usize;
for (j, b) in s.doc.body.iter().enumerate().skip(i).take(MAX_WRAPPED) {
let Some(q) = b.as_para() else { continue };
for (at, o) in q.objects_with_offsets().skip(if j == i { k } else { 0 }) {
match o {
InlineObject::FieldStart { .. } => depth += 1,
InlineObject::FieldEnd => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some((instr, off, j, at + wordcraft_doc::para::OBJ.len_utf8()));
}
}
_ => {}
}
}
}
None
}
fn list_kind(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(list_field)
}
/// The field code of `o` when it is a generated list's field.
fn list_field(o: &InlineObject) -> Option<String> {
match o {
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
}
_ => None,
}
})
}
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
@@ -199,64 +152,14 @@ pub(super) const ENTRY_STYLE: &str = "ListEntry";
pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
let mut i = 0;
while i < s.doc.body.len() {
// A list held as a field around its entries (as Word and ODT files hold them): made the
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
// What follows its closing field end, and its first paragraph's properties and section
// break, are the document's and stay.
// (The field it is made: what comes before it in its paragraph may hold other fields.)
let mut converted = None;
if let Some((instr, start, last, end)) = wrapped_list(s, i) {
// The entries between its first and last paragraphs go.
for _ in i.saturating_add(1)..last {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
// Its first paragraph keeps what comes before the field (bookmarks, other fields)
// and the field; its last (or, ending in its first, the
// same) loses what is up to and through the field end, and goes when nothing is left
// of it and it ends no section.
let first_end = if last > i {
let q = s.doc.para_mut(StoryRef::Body, &Path::top(i + 1))?;
q.delete(0, end)?;
if q.is_empty() && q.section.is_none() {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
s.doc.para_mut(StoryRef::Body, &Path::top(i))?.len()
} else {
end
};
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
p.delete(start, first_end)?;
converted = Some(instr.trim_start().to_string());
p.insert_object(
start,
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)?;
// What followed the field end in its own paragraph comes after the list, in a
// paragraph of its own that takes the section break.
let field = start + wordcraft_doc::para::OBJ.len_utf8();
if p.len() > field {
let rest = p.split_off(field)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
}
// Another list's field before it would be the paragraph's list on later updates (and
// have its shown list cleared with this one's): the list gets a paragraph of its own,
// after it, and each is rebuilt in its turn.
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
if p.objects_with_offsets().any(|(off, o)| off < start && list_field(o).is_some()) {
let list = p.split_off(start)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(list))?;
converted = None;
}
}
let kind = converted.or_else(|| s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind));
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
let Some(kind) = kind else {
i += 1;
continue;
};
add_entry_style(s);
if kind.starts_with("INDEX") {
crate::cmd::index::rebuild_from(s, i, None, kind)?;
crate::cmd::index::rebuild(s, i, None)?;
i += 1;
continue;
}
@@ -319,7 +222,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
/// layout cache starts over (and the saved file would gain a style it doesn't use).
fn add_entry_style(s: &mut Session) {
if s.doc.styles.get(ENTRY_STYLE).is_none() {
Arc::make_mut(&mut s.doc.styles).upsert(wordcraft_doc::Style {
s.doc.styles.upsert(wordcraft_doc::Style {
id: ENTRY_STYLE.into(),
name: "List Entry".into(),
kind: wordcraft_doc::StyleKind::Paragraph,
@@ -429,132 +332,3 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A field before a list's in its paragraph (an empty `PAGE`) closes first: the list still
/// ends at its own field end, paragraphs below, not in its first paragraph.
#[test]
fn a_wrapped_list_ends_at_its_own_field_end() {
let mut d = Document::from_text("Old one\nOld two\nAfter");
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(1)) {
let n = p.len();
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
}
let s = Session::new(d);
assert_eq!(wrapped_list(&s, 0).map(|(instr, _, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
}
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
/// in paragraph `last` at byte `at`.
fn wrapped(text: &str, first: usize, last: usize, at: usize) -> Session {
let mut d = Document::from_text(text);
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(last)) {
p.insert_object(at, InlineObject::FieldEnd, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(first)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
}
Session::new(d)
}
fn texts(s: &Session) -> Vec<String> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()
}
/// What follows a wrapped list's closing field end, in its last paragraph or in its only one,
/// stays, after the list; the last paragraph's section break stays too.
#[test]
fn what_follows_a_wrapped_lists_end_stays() {
// Ending in a later paragraph, with text after the end and a section break.
let mut s = wrapped("Old one\nOld two\nOld three. After\nBelow", 0, 2, "Old three.".len());
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(2)) {
p.section = Some(Box::default());
}
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.props.indent_left = Some(9.0);
}
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.iter().all(|t| !t.contains("Old")), "{t:?}");
assert!(t.iter().any(|t| t == " After"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
let after = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.plain_text() == " After");
assert!(after.is_some_and(|p| p.section.is_some()), "the section break went");
let first = s.doc.body.first().and_then(|b| b.as_para());
assert!(first.is_some_and(|p| p.props.indent_left == Some(9.0) && list_kind(p).is_some()), "{first:?}");
// An empty last paragraph without a section break goes.
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
update_generated(&mut s).unwrap();
// (The field's paragraph shows nothing: there are no sources to list.)
assert_eq!(texts(&s), ["", "Below"]);
// Ending in its own paragraph: what follows comes after the list.
let mut s = wrapped("Old entry. Then this\nBelow", 0, 0, "Old entry.".len());
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.first().is_some_and(|t| !t.contains("Old")), "{t:?}");
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
}
/// What comes before a wrapped list's field in its first paragraph (a bookmark, an empty
/// `PAGE` field) stays there, before the rebuilt list's field.
#[test]
fn what_comes_before_a_wrapped_lists_field_stays() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
let props = CharProps::default();
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "Sources".into() }, &props).unwrap();
}
update_generated(&mut s).unwrap();
let first = s.doc.body.first().and_then(|b| b.as_para());
let kinds: Vec<&str> = first
.map(|p| {
p.objects
.iter()
.map(|o| match o {
InlineObject::BookmarkStart { .. } => "bookmark",
InlineObject::FieldStart { instr, .. } if instr == "PAGE" => "page",
InlineObject::FieldEnd => "end",
InlineObject::Field { instr, .. } if instr.starts_with("BIBLIOGRAPHY") => "list",
_ => "other",
})
.collect()
})
.unwrap_or_default();
assert_eq!(kinds, ["bookmark", "page", "end", "list"]);
assert_eq!(texts(&s), ["", "Below"]);
}
/// A list field before a wrapped list's field in its paragraph doesn't take its place: the
/// wrapped list is the one rebuilt.
#[test]
fn a_wrapped_list_is_rebuilt_as_itself_after_another_list_field() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
s.doc.sources.push(wordcraft_doc::Source { tag: "a".into(), kind: "book".into(), title: "Listed Source".into(), ..Default::default() });
let index =
InlineObject::Field { instr: "INDEX".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, index, &CharProps::default()).unwrap();
}
// Updated again, each list stays itself.
for _ in 0..2 {
update_generated(&mut s).unwrap();
let t = texts(&s);
assert_eq!(t.iter().filter(|t| t.contains("Listed Source")).count(), 1, "the bibliography was rebuilt: {t:?}");
let lists: Vec<Option<String>> = s.doc.body.iter().filter_map(|b| b.as_para()).filter_map(list_kind).map(Some).collect();
assert_eq!(lists, [Some("INDEX".to_string()), Some("BIBLIOGRAPHY".to_string())]);
}
}
}
+4 -7
View File
@@ -7,7 +7,6 @@
//! typing and deleting commands.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::control::{ContentControl, ControlKind, ControlLock, ControlRange, ListItem, MAX_CONTROL_TEXT, MAX_LIST_ITEMS};
use wordcraft_doc::para::OBJ;
use wordcraft_doc::props::{CharProps, Rgb, TextColor};
@@ -173,7 +172,7 @@ fn placeholder_style_id(doc: &wordcraft_doc::Document) -> String {
/// The placeholder style, added to the document when it hasn't one.
fn ensure_placeholder_style(s: &mut Session) {
if s.doc.styles.get(&placeholder_style_id(&s.doc)).is_none() {
Arc::make_mut(&mut s.doc.styles).styles.push(Style {
s.doc.styles.styles.push(Style {
id: PLACEHOLDER_STYLE.into(),
name: PLACEHOLDER_STYLE_NAME.into(),
kind: StyleKind::Character,
@@ -800,8 +799,6 @@ mod tests {
#[cfg(test)]
mod editing_tests {
use std::sync::Arc;
use serde_json::json;
use wordcraft_doc::StoryRef;
use wordcraft_doc::control::ControlKind;
@@ -832,8 +829,8 @@ mod editing_tests {
let typed_style = |run_props: Option<CharProps>| {
let mut s = Session::new(wordcraft_doc::Document::new());
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
Arc::make_mut(&mut s.doc.styles).styles.push(style("Platzhaltertext", "Placeholder Text", grey));
Arc::make_mut(&mut s.doc.styles).styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
s.doc.styles.styles.push(style("Platzhaltertext", "Placeholder Text", grey));
s.doc.styles.styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
run(&mut s, "text.insert", json!({"text": "Name: "}));
run(&mut s, "developer.plainText", json!({"tag": "name"}));
let start = s.doc.control_at(&s.sel.focus).unwrap().start;
@@ -855,7 +852,7 @@ mod editing_tests {
// style goes all the same.
let mut s = Session::new(wordcraft_doc::Document::new());
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
Arc::make_mut(&mut s.doc.styles).styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
s.doc.styles.styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
run(&mut s, "text.insert", json!({"text": "Nom : "}));
run(&mut s, "developer.plainText", json!({"tag": "nom"}));
let r = s.doc.control_at(&s.sel.focus).unwrap();
+9 -11
View File
@@ -1,7 +1,6 @@
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
@@ -96,8 +95,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn set_fonts(s: &mut Session, heading: &str, body: &str) {
s.doc.settings.major_font = heading.into();
s.doc.settings.minor_font = body.into();
Arc::make_mut(&mut s.doc.styles).default_chr.font = Some(body.into());
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
s.doc.styles.default_chr.font = Some(body.into());
for st in &mut s.doc.styles.styles {
if let Some(f) = &st.chr.font
&& (f == styles::HEADING_FONT || st.id.starts_with("Heading") || st.id == "Title")
{
@@ -109,7 +108,7 @@ fn set_fonts(s: &mut Session, heading: &str, body: &str) {
}
fn set_accent(s: &mut Session, accent: Rgb) {
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
for st in &mut s.doc.styles.styles {
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5')
|| st.id == "IntenseQuote"
|| st.id == "IntenseEmphasis"
@@ -174,16 +173,15 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
}
// Overlay the set onto the document's sheet: every other style, the latent styles and the
// document defaults stay as they are (no set defines its own defaults).
let sheet = Arc::make_mut(&mut s.doc.styles);
for mut st in fresh.styles {
match sheet.styles.iter_mut().find(|x| x.id == st.id) {
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
Some(old) => {
st.default = old.default;
st.locked = old.locked;
st.builtin = old.builtin;
*old = st;
}
None => sheet.styles.push(st),
None => s.doc.styles.styles.push(st),
}
}
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
@@ -202,9 +200,9 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
"double" => (0.0, 8.0, 2.0),
_ => (0.0, 8.0, 1.15),
};
Arc::make_mut(&mut s.doc.styles).default_para.space_before = Some(before);
Arc::make_mut(&mut s.doc.styles).default_para.space_after = Some(after);
Arc::make_mut(&mut s.doc.styles).default_para.line_spacing = Some(LineSpacing::Multiple(line));
s.doc.styles.default_para.space_before = Some(before);
s.doc.styles.default_para.space_after = Some(after);
s.doc.styles.default_para.line_spacing = Some(LineSpacing::Multiple(line));
sel_result(s)
}
@@ -229,7 +227,7 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
c.size = c.size.map(scale);
c.size_cs = c.size_cs.map(scale);
};
let st = Arc::make_mut(&mut s.doc.styles);
let st = &mut s.doc.styles;
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
-1
View File
@@ -880,7 +880,6 @@ fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdErro
};
*kind = new_kind;
*freeform = geometry;
extra.odf_geometry = None;
*fill = None;
let [_, _, raw_w, raw_h] = found.bounds();
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
+5 -8
View File
@@ -1,7 +1,6 @@
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::{Block, Document, StoryRef};
use super::{pos_json, sel_result};
@@ -128,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
.params(
r#"{"path": string, "pagesPerSheet"?: 1 | 2 | 4 | 6 | 9 | 16 | "booklet" | "bookletReverse", "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
@@ -231,7 +230,7 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn normal_document(s: &Session) -> Document {
let mut d = s.blank_document();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
Arc::make_mut(&mut d.styles).default_chr.lang = Some(lang.clone());
d.styles.default_chr.lang = Some(lang.clone());
}
d
}
@@ -272,7 +271,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
doc.styles.default_chr.lang = Some(lang.clone());
}
match s.used_because() {
None => s.set_document(doc),
@@ -346,9 +345,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
return Err(CmdError::Params(
"only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password".into(),
));
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.clear_recovery_before_password()?;
@@ -383,7 +380,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
+1 -20
View File
@@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge;
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
use wordcraft_doc::section::SectionProps;
use wordcraft_doc::table::{Cell, Row, Table};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, Revision, RevisionKind, StoryRef};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind};
use crate::Session;
@@ -212,25 +212,6 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
/// another author's is superseded.
pub(crate) fn record_para_props(s: &mut Session, story: StoryRef, path: &Path, before: &ParaProps) -> Result<(), crate::CmdError> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return Ok(());
}
let Some(after) = s.doc.para(story, path).map(|p| p.props.clone()) else { return Ok(()) };
let revisions = s.doc.revisions.clone();
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &revisions, revisions: &mut s.doc.revisions, rid: None };
if let Some(Replacement(want)) = r.change(before, &after, ParaProps::formatting, |p| &p.fmt_change) {
let p = s.doc.para_mut(story, path)?;
p.props.fmt_change = want;
p.touch();
}
Ok(())
}
/// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted
/// differently from `old` as tracked formatting changes by `author`.
pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec<Revision>, author: &str, date: &str) {
+1 -104
View File
@@ -1,6 +1,4 @@
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`; and
//! the fonts the document's text asks for that aren't installed, with the fonts drawn instead
//! (`font.missing`).
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
//!
//! An added font joins the process-wide font database on top of the bundled and system fonts
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
@@ -8,9 +6,7 @@
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
use serde_json::{Value, json};
use wordcraft_doc::Document;
use wordcraft_fonts::{FontDb, UserFont};
use wordcraft_layout::DocLayout;
use crate::cmd::insert::base64_decode;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -23,9 +19,6 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(),
CommandSpec::new("font.missing", "Missing Fonts", "Status Bar", missing)
.params(r#"{} → {"missing": [{"font": string, "shownAs": [string] (the families drawn instead), "metricCompatible": bool (its substitute measures like the font)}]} — the text the current view shows"#)
.pure(),
]
}
@@ -124,102 +117,6 @@ fn remove(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
}
/// A font the document's text asks for that isn't installed.
#[derive(Clone, Debug, PartialEq)]
pub struct MissingFont {
/// The name the document asks for.
pub font: String,
/// The families its text is drawn in instead: the substitute, and fallback faces for
/// characters the substitute lacks.
pub shown_as: Vec<String>,
/// The font's substitute measures like it (Carlito for Calibri…): line breaks don't move.
/// Fallback faces for characters the substitute lacks don't count: the font itself might
/// lack them too (an emoji in Calibri text).
pub metric_compatible: bool,
}
/// The family text set in `font` is drawn in when `font` isn't installed (None when it is), as
/// layout picks it for `doc`.
pub fn shown_as(doc: &Document, font: &str) -> Option<String> {
wordcraft_layout::para::substitute_face(doc, font).map(|f| f.get().family.clone())
}
/// The family Chinese, Japanese and Korean text is drawn in when its East Asian font `font_ea`
/// isn't installed (None when it is), the run's font being `font`, as layout picks it for `doc`.
pub fn east_asian_shown_as(doc: &Document, font_ea: &str, font: &str) -> Option<String> {
wordcraft_layout::para::east_asian_substitute_face(doc, font_ea, font).map(|f| f.get().family.clone())
}
/// Whether text in `font` drawn in `substitute` is worth a warning: the substitute measures
/// differently, so line breaks can move, and the font isn't a symbol font (drawn as its
/// Unicode equivalents on purpose, which layout leaves out of [`missing_fonts`] too). Decides
/// [`MissingFont::metric_compatible`], which the font box and the status bar both read.
pub fn warns(font: &str, substitute: &str) -> bool {
!wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::metric_compatible(font, substitute)
}
/// The fonts `doc`'s laid-out text asks for that aren't installed, from what layout drew
/// ([`DocLayout::substituted_fonts`]): only text the current view shows counts (no headers or
/// footers in Web, Draft or Read view), in every story on its pages, list labels and field
/// results included. Names differing only in case are one font.
pub fn missing_fonts(doc: &Document, layout: &DocLayout) -> Vec<MissingFont> {
// By key: in name order, case variants merged.
let mut out: std::collections::BTreeMap<String, MissingFont> = std::collections::BTreeMap::new();
for (recorded, face) in layout.substituted_fonts() {
let family = face.get().family.clone();
let font = shown_name(&recorded);
let m = out.entry(font_key(font)).or_insert_with(|| MissingFont {
font: font.to_string(),
shown_as: Vec::new(),
// Judged by the name layout looked up, spaces and all: "Arial " isn't Arial to it, so
// its text is in a substitute even where Arial is installed.
metric_compatible: shown_as(doc, &recorded).is_some_and(|s| !warns(font, &s)),
});
if !m.shown_as.contains(&family) {
m.shown_as.push(family);
}
}
out.into_values().collect()
}
/// A font name as [`missing_fonts`] lists it: as layout records it (at most 256 characters),
/// trimmed.
fn shown_name(font: &str) -> &str {
wordcraft_layout::para::reported_font_name(font).trim()
}
/// The key a font name is listed under in [`missing_fonts`]: two names with the same key are
/// one font. Look a document's font up in the list through this.
pub fn font_key(font: &str) -> String {
shown_name(font).to_lowercase()
}
/// The status line's form of a font name: a hostile file's names are long.
fn short_name(font: &str) -> String {
match font.char_indices().nth(40) {
Some((i, _)) => format!("{}…", font.get(..i).unwrap_or(font)),
None => font.to_string(),
}
}
fn missing(s: &mut Session, _: &Value) -> CmdResult {
let layout = s.layout();
let found = missing_fonts(&s.doc, &layout);
// From command search there's no dialog: the status line says what was found.
// A hostile file can name thousands of fonts: name the first few.
const NAMED: usize = 5;
let changing: Vec<&str> = found.iter().filter(|f| !f.metric_compatible).map(|f| f.font.as_str()).collect();
let named = changing.iter().take(NAMED).map(|f| short_name(f)).collect::<Vec<_>>().join(", ");
s.status = match (found.is_empty(), changing.len()) {
(true, _) => "Every font the document shows is installed".into(),
(false, 0) => "Fonts that aren't installed are shown in fonts that measure the same: line breaks don't move".into(),
(_, n) if n > NAMED => format!("Not installed: {named} and {} more", n - NAMED),
_ => format!("Not installed: {named}"),
};
let fonts: Vec<Value> = found.iter().map(|f| json!({"font": f.font, "shownAs": f.shown_as, "metricCompatible": f.metric_compatible})).collect();
Ok(json!({"missing": fonts}))
}
#[cfg(test)]
mod tests {
use super::*;
+1 -19
View File
@@ -138,17 +138,6 @@ pub fn specs() -> Vec<CommandSpec> {
apply(s, &|c| c.position = Some(x))
})
.params(r#"{"points": number}"#),
// Font › Emphasis mark: a mark over (or under) each character.
CommandSpec::new("format.emphasisMark", "Emphasis Mark", "Home › Font › Font", |s, v| {
use wordcraft_doc::props::EmphasisMark;
let name = p::str(v, "mark").unwrap_or("dot");
let mark = EmphasisMark::ALL
.into_iter()
.find(|m| m.ooxml().eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| CmdError::Params(format!("unknown emphasis mark `{name}` (none, dot, comma, circle, underDot)")))?;
apply(s, &|c| c.emphasis = Some(mark))
})
.params(r#"{"mark"?: "none" | "dot" | "comma" | "circle" | "underDot"}"#),
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
@@ -877,14 +866,8 @@ pub fn state(s: &Session) -> Value {
let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a);
let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
let font = same(&|r| json!(r.font));
// The font's substitute when it isn't installed: the face its text is drawn in, apart from
// characters the substitute lacks. `font.missing` lists every face the shown text is drawn
// in, fallback faces too, and which fonts change the layout.
let shown_as = font.as_str().and_then(|f| crate::cmd::font::shown_as(&s.doc, f));
json!({
"font": font,
"fontShownAs": shown_as,
"font": same(&|r| json!(r.font)),
"fontEastAsia": same(&|r| json!(r.font_ea)),
"size": same(&|r| json!(r.size)),
"bold": all.iter().all(|r| r.bold),
@@ -899,7 +882,6 @@ pub fn state(s: &Session) -> Value {
"caps": all.iter().all(|r| r.caps),
"hidden": all.iter().all(|r| r.hidden),
"spacing": same(&|r| json!(r.spacing)),
"emphasisMark": same(&|r| json!(r.emphasis.ooxml())),
// Kerning for fonts: the smallest kerned size, points; 0 = off.
"kern": same(&|r| json!(r.kern)),
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
+3 -40
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::section::SectionStart;
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
use super::references::{FieldCode, field_arg, leader};
use super::references::{FieldCode, leader};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -44,12 +44,11 @@ fn xe_instr(s: &Session, v: &Value, main: &str) -> Result<String, CmdError> {
let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") };
if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) {
instr.push_str(&format!(" \\t \"{see}\""));
} else if let Some(bm) = p::str(v, "bookmark").map(str::trim).filter(|b| !b.is_empty()) {
} else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) {
if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) {
return Err(CmdError::Params(format!("no bookmark `{bm}`")));
}
// A name read from a file may hold a space, a quote or a backslash.
instr.push_str(&format!(" \\r {}", field_arg(bm)));
instr.push_str(&format!(" \\r {}", bm.trim()));
}
if p::bool(v, "bold").unwrap_or(false) {
instr.push_str(" \\b");
@@ -265,12 +264,6 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
}) else {
return Ok(());
};
rebuild_from(s, i, leader, instr)
}
/// [`rebuild`] the index at body block `i` from field code `instr` (that of the index field in
/// it, when the paragraph holds more than one).
pub fn rebuild_from(s: &mut Session, i: usize, leader: Option<TabLeader>, instr: String) -> Result<(), CmdError> {
let f = FieldCode::parse(&instr);
// Old entries go; keep their leader and the section their columns had.
let mut old_leader = None;
@@ -400,36 +393,6 @@ mod tests {
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect()
}
/// #3669: the bookmark is looked up trimmed, and a name with a space or a quote (one a file
/// can hold) is quoted in the XE field, so it reads back whole.
#[test]
fn mark_entry_trims_and_quotes_its_bookmark() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro text.\nMore text."));
s.run("select.text", &json!({"text": "Intro"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Intro"})).unwrap();
let odd = "My \"Mark\" \\b";
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: odd.into() }, &CharProps::default()).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.markEntry", &json!({"entry": "Alpha", "bookmark": " Intro "})).unwrap();
s.run("references.markEntry", &json!({"entry": "Beta", "bookmark": odd})).unwrap();
let mut instrs: Vec<String> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.filter_map(|o| match o {
InlineObject::Field { instr, .. } if instr.starts_with("XE") => Some(instr.clone()),
_ => None,
})
.collect();
instrs.sort();
assert_eq!(instrs, vec![r#"XE "Alpha" \r Intro"#, r#"XE "Beta" \r "My \"Mark\" \\b""#]);
let beta = FieldCode::parse(&instrs[1]);
assert_eq!((beta.arg('r'), beta.has('b')), (Some(odd), false));
}
/// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and
/// the INDEX field's switches change the index: run-in subentries, letter headings, a tab
/// before right-aligned page numbers, and two columns in a continuous section.
+2 -2
View File
@@ -15,7 +15,7 @@ mod objects;
pub(crate) use header_footer::saved_copy;
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_parts};
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
use links::{bookmark, delete_bookmark, link, link_targets};
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
.when(drop_cap_off),
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
super::para::fmt(s, &|p| {
+16 -71
View File
@@ -61,16 +61,14 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
pub fn format_date(fmt: &str) -> String {
// Word shows the computer's local date and time (#1707), not UTC's.
let now = crate::cmd::now_unix();
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC.
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
format_date_parts(fmt, y, m as usize, d, hms, days)
let iso = crate::cmd::now_iso();
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
let ss: u32 = iso.get(17..19).and_then(|x| x.parse().ok()).unwrap_or(0);
format_date_parts(fmt, y, m, d, (hh, mm, ss), (crate::cmd::now_unix() / 86_400) as i64)
}
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
@@ -85,28 +83,6 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
let mut i = 0;
while i < chars.len() {
let c = chars.get(i).copied().unwrap_or(' ');
// Text in single quotes is shown as it is; two quotes in a row are one, inside quoted
// text or out of it.
if c == '\'' && chars.get(i + 1) == Some(&'\'') {
out.push('\'');
i += 2;
continue;
}
if c == '\'' {
i += 1;
while let Some(&q) = chars.get(i) {
i += 1;
if q != '\'' {
out.push(q);
} else if chars.get(i) == Some(&'\'') {
out.push('\'');
i += 1;
} else {
break;
}
}
continue;
}
// `am/pm` (any case) is one token: AM or PM.
if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::<String>().eq_ignore_ascii_case("am/pm")) {
out.push_str(if hh < 12 { "AM" } else { "PM" });
@@ -241,16 +217,12 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
/// margin need), unless a paragraph in it has a drop cap for None to take away.
/// and in one that starts with a space.
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
let why = drop_cap_selection_off(s)?;
let story = s.sel.focus.story;
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
(!has_cap).then_some(why)
}
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
@@ -258,21 +230,6 @@ fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
}
}
/// The same check for every selected paragraph, as the command formats them all.
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
let story = s.sel.focus.story;
for path in crate::cmd::selected_paths(s) {
if path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
if let Some(why) = drop_cap_first_letter(first) {
return Some(why);
}
}
None
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
@@ -280,27 +237,15 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines_given = lines.is_some();
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
if !none && let Some(why) = drop_cap_selection_off(s) {
return Err(CmdError::Failed(why.into()));
}
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
// lines, distance and font unless given here.
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
crate::cmd::para::fmt(s, &|p| {
let own = keep && p.drop_cap.is_some();
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
p.drop_cap = (!none).then_some(lines);
p.drop_cap_margin = (!none && margin).then_some(true);
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
if !(own && !font_given) {
p.drop_cap_font = font.clone().filter(|_| !none);
}
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
})
}
+2 -12
View File
@@ -137,8 +137,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
let (mut w, mut h) = (natural_w, natural_h);
if let Some(want) = width {
// At most 22 inches, as for icons: a huge width made the height of a tall picture infinite.
let k = want.clamp(4.0, 1584.0) / w.max(1.0);
let k = want.max(4.0) / w.max(1.0);
w *= k;
h *= k;
}
@@ -146,16 +145,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
h *= max_w / w;
w = max_w;
}
let obj = InlineObject::Image {
media: key.clone(),
w,
h,
alt: alt.to_string(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
// A selected Drawing Canvas takes it in.
if let Some(i) = crate::cmd::canvas::add_member(s, obj.clone())? {
return Ok(json!({"media": key, "canvasMember": i}));
-62
View File
@@ -1,20 +1,5 @@
use super::*;
/// #3619: a huge finite width can't make a tall picture's height infinite, which a `.json` save
/// writes as `null` and can't read back.
#[test]
fn a_huge_picture_width_keeps_the_size_finite() {
let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255]));
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap();
let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap());
assert!(w.is_finite() && h.is_finite() && (h / w - 200.0).abs() < 0.01, "{w} x {h}");
let saved = crate::io::save_bytes("a.json", &s.doc).unwrap();
assert!(crate::io::open_bytes("a.json", &saved).is_ok());
}
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
fn jpeg_strip_tiff() -> Vec<u8> {
let mut t = Vec::new();
@@ -349,18 +334,6 @@ fn symbol_takes_its_own_font() {
assert!(s.run("insert.symbol", &json!({"char": ""})).is_err());
}
#[test]
fn date_pictures_keep_quoted_text() {
// Text in single quotes is shown as it is (Word's date-time picture); two quotes are one.
assert_eq!(format_date_parts("H' o''clock'", 2026, 10, 21, (8, 0, 0), 0), "8 o'clock");
assert_eq!(format_date_parts("'Day' d 'at' h", 2026, 10, 21, (17, 0, 0), 0), "Day 21 at 5");
// An unclosed quote runs to the end.
assert_eq!(format_date_parts("yyyy 'and so", 2026, 10, 21, (0, 0, 0), 0), "2026 and so");
// Two quotes outside quoted text are one too (an apostrophe), not an empty quoted text.
assert_eq!(format_date_parts("yyyy '' MM", 2026, 10, 21, (0, 0, 0), 0), "2026 ' 10");
assert_eq!(format_date_parts("d''MM", 2026, 10, 21, (0, 0, 0), 0), "21'10");
}
#[test]
fn date_pictures() {
// Word accepts either case for the day and year instructions.
@@ -381,38 +354,3 @@ fn date_pictures() {
assert_eq!(date_picture(r#"TIME \@ HH:mm"#).as_deref(), Some("HH:mm"));
assert_eq!(date_picture("DATE"), None);
}
/// #1707: Insert ▸ Date & Time and DATE/TIME fields use the local civil date, time and
/// weekday, not UTC's.
#[test]
fn format_date_uses_the_local_offset() {
// Noon on Saturday 31 October in New Zealand (UTC+13) is 2026-10-30T23:00:00Z.
assert_eq!(format_date_at("dddd, MMMM d, yyyy h:mm am/pm", 1_793_401_200, 13 * 3600), "Saturday, October 31, 2026 12:00 PM");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_498_400, -5 * 3600), "10/31/2026 21:00");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_401_200, 0), "10/30/2026 23:00");
}
#[test]
fn local_civil_date_follows_the_utc_offset() {
let pic = "dddd d MMMM yyyy HH:mm";
let show = |unix: u64, off: i64| {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, off);
format_date_parts(pic, y, m as usize, d, hms, days)
};
// 2026-10-30T23:00:00Z in New Zealand (UTC+13) is Saturday 31 October, noon.
assert_eq!(show(1_793_401_200, 13 * 3600), "Saturday 31 October 2026 12:00");
// 2026-11-01T02:00:00Z at UTC-5 is still Saturday 31 October, 21:00.
assert_eq!(show(1_793_498_400, -5 * 3600), "Saturday 31 October 2026 21:00");
// Offset 0 is UTC.
assert_eq!(show(1_793_401_200, 0), "Friday 30 October 2026 23:00");
// Near the epoch with a negative offset: the day before 1970, no overflow.
assert_eq!(show(0, -3600), "Wednesday 31 December 1969 23:00");
// Hostile values never panic.
let _ = crate::cmd::local_civil(u64::MAX, i64::MAX);
let _ = crate::cmd::local_civil(u64::MAX, i64::MIN);
let _ = crate::cmd::local_civil(0, i64::MIN);
// The machine's zone is a real offset (within ±26 h) and asking never panics.
for unix in [0, 1_793_401_200, u64::MAX] {
assert!(crate::cmd::local_offset_secs(unix).abs() <= 26 * 3600);
}
}
+6 -7
View File
@@ -4,7 +4,6 @@
//! the number and the number's font.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::StoryRef;
use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for};
use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor};
@@ -184,26 +183,26 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
Some(n) => {
// Change the list in place: every list sharing its definition follows.
let aid = s.doc.numbering.num(n).map(|x| x.abstract_id).ok_or_else(|| CmdError::Params("no such list".into()))?;
if let Some(a) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == aid) {
if let Some(a) = s.doc.numbering.abstracts.iter_mut().find(|a| a.id == aid) {
a.levels = levels.clone();
if name.is_some() {
a.name = name;
}
}
if let Some(x) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == n) {
if let Some(x) = s.doc.numbering.nums.iter_mut().find(|x| x.id == n) {
x.level_overrides.clear();
}
let sharing: Vec<u32> = s.doc.numbering.nums.iter().filter(|x| x.abstract_id == aid).map(|x| x.id).collect();
touch_lists(s, &sharing)?;
n
}
None => Arc::make_mut(&mut s.doc.numbering).try_add_levels(name, levels.clone())?,
None => s.doc.numbering.try_add_levels(name, levels.clone())?,
};
// Levels linked to a paragraph style number every paragraph in that style.
let mut linked = Vec::new();
for (i, l) in levels.iter().enumerate() {
let Some(id) = l.style.as_deref().map(|st| s.doc.styles.find(st).map(|x| x.id.clone()).unwrap_or_else(|| st.to_string())) else { continue };
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
if let Some(st) = s.doc.styles.get_mut(&id) {
st.para.numbering = Some(NumRef { num, level: i as u8 });
linked.push(id);
}
@@ -293,11 +292,11 @@ fn preset_levels(preset: &str) -> Option<Vec<Level>> {
fn preset(s: &mut Session, v: &Value) -> CmdResult {
let id = p::req_str(v, "preset")?;
let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?;
let num = Arc::make_mut(&mut s.doc.numbering).try_add_levels(None, levels)?;
let num = s.doc.numbering.try_add_levels(None, levels)?;
let mut linked = Vec::new();
for n in 1..=LEVELS as u8 {
let id = super::para::ensure_heading_style(s, n);
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
if let Some(st) = s.doc.styles.get_mut(&id) {
st.para.numbering = Some(NumRef { num, level: n - 1 });
linked.push(id);
}
+11 -8
View File
@@ -7,7 +7,6 @@ use wordcraft_doc::props::{Align, CharProps};
use wordcraft_doc::section::{SectionProps, SectionStart};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Part, PartKind, Pos, StoryRef, Table, para_block};
use super::references::{field_arg, field_words, quoted};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -123,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| {
let f = p::req_str(v, "field")?.to_string();
insert_field(s, &format!("MERGEFIELD {}", field_arg(&f)), &format!("«{f}»"))
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
})
.params(r#"{"field": string}"#)
.key(super::non_mac("Alt+Shift+F")),
@@ -137,14 +136,14 @@ pub fn specs() -> Vec<CommandSpec> {
let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase();
let instr = match rule.as_str() {
"IF" => format!(
"IF {{ MERGEFIELD {} }} = {} {} {}",
field_arg(p::str(v, "field").unwrap_or("Field")),
quoted(p::str(v, "value").unwrap_or("")),
quoted(p::str(v, "then").unwrap_or("")),
quoted(p::str(v, "else").unwrap_or(""))
"IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"",
quote(p::str(v, "field").unwrap_or("Field")),
p::str(v, "value").unwrap_or(""),
p::str(v, "then").unwrap_or(""),
p::str(v, "else").unwrap_or("")
),
"SKIPIF" | "NEXTIF" => {
format!("{rule} {{ MERGEFIELD {} }} = {}", field_arg(p::str(v, "field").unwrap_or("Field")), quoted(p::str(v, "value").unwrap_or("")))
format!("{rule} {{ MERGEFIELD {} }} = \"{}\"", quote(p::str(v, "field").unwrap_or("Field")), p::str(v, "value").unwrap_or(""))
}
"MERGEREC" => "MERGEREC".into(),
_ => "NEXT".into(),
@@ -228,6 +227,10 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn quote(f: &str) -> String {
if f.contains(' ') { format!("\"{f}\"") } else { f.to_string() }
}
fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
let props = s.typing_props();
let at = delete_selection(s)?;
+10 -8
View File
@@ -1,6 +1,6 @@
use super::{
Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value,
field_words, json, p, para_block,
json, p, para_block,
};
pub(super) fn step(s: &mut Session, d: i64) -> CmdResult {
@@ -41,7 +41,9 @@ pub(super) fn mergefield_name(instr: &str) -> Option<String> {
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
return None;
}
Some(field_words(rest).into_iter().next().map(|(name, _)| name).unwrap_or_default())
let rest = rest.trim();
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
Some(name.to_string())
}
/// Which columns hold address parts.
@@ -101,17 +103,17 @@ fn rule_field(t: &str) -> Option<String> {
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
let field = rule_field(t).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let mut texts = field_words(rest).into_iter().filter(|(_, q)| *q).map(|(w, _)| w);
let cmp = texts.next().unwrap_or_default();
let mut quoted = rest.split('"').skip(1).step_by(2);
let cmp = quoted.next().unwrap_or("");
let v = value(s, row, &field);
// Numbers compare as numbers (Word: "7" = "7.0"), everything else as text.
let ord = match (v.trim().parse::<f64>(), cmp.trim().parse::<f64>()) {
(Ok(a), Ok(b)) => a.partial_cmp(&b),
_ => Some(v.as_str().cmp(cmp.as_str())),
_ => Some(v.as_str().cmp(cmp)),
};
use std::cmp::Ordering::{Equal, Greater, Less};
let holds = match op.as_str() {
@@ -122,7 +124,7 @@ fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
">=" => matches!(ord, Some(Greater | Equal)),
_ => ord == Some(Equal),
};
(holds, texts.collect())
(holds, quoted.collect())
}
/// The first word of a field instruction, upper-cased.
@@ -147,7 +149,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
"IF" if rule_field(t).is_some() => {
let (holds, texts) = condition(s, t, row);
Act::Text(texts.get(if holds { 0 } else { 1 }).cloned().unwrap_or_default())
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
}
_ => return None,
})
-23
View File
@@ -307,29 +307,6 @@ fn if_rule_previews_its_result() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Selected\nOther");
}
/// #3681: quotes and backslashes in a rule's texts and field name stay in them.
#[test]
fn rule_texts_keep_their_quotes() {
let mut s = Session::new(Document::new());
let rows = json!([{"Say \"hi\"": "a\\b", "Name": "O\"Neil"}, {"Say \"hi\"": "no", "Name": "Plain"}]);
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
let rule = json!({"rule": "IF", "field": "Say \"hi\"", "value": "a\\b", "then": "Dear \"friend\"", "else": "C:\\dir\\"});
s.run("mailings.rules", &rule).unwrap();
s.run("mailings.preview", &json!({"value": true})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Dear \"friend\"");
s.run("mailings.next", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "C:\\dir\\");
// SKIPIF compares with the value as typed.
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("mailings.rules", &json!({"rule": "SKIPIF", "field": "Name", "value": "O\"Neil"})).unwrap();
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Plain");
}
/// #430: merge fields in headers and footers are previewed and merged; each letter gets its
/// own header.
#[test]
+1 -1
View File
@@ -31,7 +31,7 @@ use picture::{clear_background, grown, picture_style};
pub(crate) use select::all_objects;
pub(crate) use select::{edit_obj, has_shape_or_canvas};
#[cfg(test)]
use select::{has_group, holds_ink, obj_size, with_obj};
use select::{has_group, holds_ink, obj_size};
pub use select::{min_size, object_selection, picked_objects, selected, selected_all};
#[cfg(test)]
use shape::can_link;
+17 -48
View File
@@ -1,6 +1,5 @@
use super::select::{obj_size, selected, with_obj};
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
use wordcraft_doc::picfx::PicFx;
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
@@ -149,33 +148,13 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
pub(super) fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, |_| {}, f)
}
/// The selected picture's pre-edit media key (itself when never edited).
pub(super) fn original_media(s: &Session) -> Result<String, CmdError> {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
Ok(s.originals.get(&media).cloned().unwrap_or(media))
}
/// Drops brightness and contrast read from the file: an edit baked them from the original.
pub(super) fn clear_lum(fx: &mut PicFx) {
fx.bright = 0;
fx.contrast = 0;
adjust_media(s, &media, f)
}
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
/// `fx` changes the picture's brightness, contrast and transparency from the file (see
/// [`PicFx`]) for an edit that bakes its own.
pub(super) fn adjust_media(
s: &mut Session,
base: &str,
fx: impl Fn(&mut PicFx),
f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>,
) -> CmdResult {
pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
@@ -200,9 +179,8 @@ pub(super) fn adjust_media(
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
s.originals.entry(key.clone()).or_insert(orig);
with_obj(s, |o| {
if let InlineObject::Image { media, fx: pic_fx, .. } = o {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
fx(pic_fx);
}
})
}
@@ -266,25 +244,19 @@ pub(super) fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) ->
});
}
let l = look.clone();
// Styles, borders and effects leave the file's brightness, contrast and transparency alone.
let out = adjust_media(
s,
&look.base,
|_| {},
move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
},
)?;
let out = adjust_media(s, &look.base, move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
})?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
{
@@ -327,13 +299,10 @@ pub(super) fn reset(s: &mut Session, _: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let orig = s.originals.get(&media).cloned().unwrap_or(media);
// Like Word, Reset Picture discards every correction, those read from the file (`fx`:
// brightness, contrast, transparency, #1711, #1766) included.
with_obj(s, |o| {
if let InlineObject::Image { media, crop, fx, .. } = o {
if let InlineObject::Image { media, crop, .. } = o {
*media = orig.clone();
*crop = [0.0; 4];
*fx = PicFx::default();
}
})
}
+14 -17
View File
@@ -1,13 +1,12 @@
use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::picture::{adjust, adjust_media, crop, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
use wordcraft_doc::picfx::PicFx;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -37,9 +36,10 @@ pub fn specs() -> Vec<CommandSpec> {
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
// Absolute from the original (like transparency): re-setting all three to 0
// restores the original pixels instead of stacking on the last pass.
// The file's own brightness and contrast (`fx`) are replaced, not stacked on.
let base = original_media(s)?;
adjust_media(s, &base, clear_lum, move |img| {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
let mut img = img;
if b != 0.0 {
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
@@ -62,11 +62,10 @@ pub fn specs() -> Vec<CommandSpec> {
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
let sat = p::f32(v, "saturation").unwrap_or(100.0);
// Absolute from the original (like transparency): saturation 100 restores it.
// Color and Corrections are independent, as in Word: only Washout, which Word stores
// as brightness and contrast (`a:lum`), replaces the file's own (`fx`).
let base = original_media(s)?;
let fx: fn(&mut PicFx) = if mode == "washout" { clear_lum } else { |_| {} };
adjust_media(s, &base, fx, move |img| Ok(recolor(img, &mode, sat)))
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -98,10 +97,10 @@ pub fn specs() -> Vec<CommandSpec> {
// slider back and forth never fades what a previous pass already faded.
// (Combining with other pixel edits then re-setting transparency drops those
// edits; a full live-effect stack is future work.)
// The file's own transparency (`fx`, `a:alphaModFix`) is replaced too: it's baked
// into the pixels from here on.
let base = original_media(s)?;
adjust_media(s, &base, |fx: &mut PicFx| fx.transparency = 0, move |img| {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
@@ -310,10 +309,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_obj(s, |o| {
if let InlineObject::Shape { kind: k, extra, .. } = o {
if let InlineObject::Shape { kind: k, .. } = o {
*k = kind;
// A geometry kept from a file is no longer this shape's.
extra.odf_geometry = None;
}
})
})
-103
View File
@@ -1,5 +1,4 @@
use super::*;
use wordcraft_doc::picfx::PicFx;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| {
@@ -455,108 +454,6 @@ fn transparency_is_absolute_from_the_original() {
assert_eq!(rgba(&s), orig, "reset still finds the original");
}
/// A picture opened with brightness, contrast and transparency from its file (`fx`, #1711,
/// #1766): Transparency and Corrections are absolute and replace the file's values instead of
/// stacking on them; Color leaves the file's brightness and contrast alone except Washout
/// (itself stored as brightness and contrast); Reset Picture discards every correction, the
/// file's included, as in Word.
#[test]
fn picture_edits_replace_the_file_corrections() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
let imported = PicFx::new(80_000, -80_000, 50_000);
let set_fx = |s: &mut Session, v: PicFx| {
with_obj(s, |o| {
if let InlineObject::Image { fx, .. } = o {
*fx = v;
}
})
.unwrap()
};
set_fx(&mut s, imported);
let state = |s: &Session| {
let (_, o) = selected(s).unwrap();
let InlineObject::Image { media, fx, .. } = o else { panic!("a picture") };
let bytes = s.doc.media.get(&media).cloned().unwrap();
(media, fx, image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
};
let (orig_media, _, orig) = state(&s);
// Transparency 50%: 50% (not 75%), the file's transparency dropped, its lum kept.
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!(fx, PicFx::new(80_000, -80_000, 0));
assert!(px.iter().all(|p| p[3] == 127));
// 0%: opaque, no `a:alphaModFix` left to write.
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx.transparency, px), (0, orig.clone()));
// Color other than Washout keeps the file's lum; Washout replaces it.
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "washout"})).unwrap();
assert_eq!(state(&s).1, PicFx::default());
// Corrections bake from the original and replace the file's lum.
set_fx(&mut s, imported);
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx, px), (PicFx::new(0, 0, 50_000), orig.clone()));
// Reset: the imported bitmap, no corrections at all.
set_fx(&mut s, imported);
s.run("picture.reset", &json!({})).unwrap();
let (media, fx, px) = state(&s);
assert_eq!((media, fx, px), (orig_media, PicFx::default(), orig));
}
/// Two pictures showing the same media, one with Washout from its file and one without:
/// edits on both bake to the same bytes, and resetting the plain one leaves it without
/// corrections (it used to be able to pick up the other's).
#[test]
fn reset_never_borrows_another_pictures_corrections() {
let mut d = wordcraft_doc::Document::new();
let media = d.add_media(png(20, 10), "png");
let washout = PicFx::new(70_000, -70_000, 0);
let mut p = wordcraft_doc::Paragraph::new();
for fx in [PicFx::default(), washout] {
let pic = InlineObject::Image {
media: media.clone(),
w: 40.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx,
ole: None,
};
p.insert_object(0, pic, &Default::default()).unwrap();
}
d.body = vec![wordcraft_doc::para_block(p)];
let mut s = Session::new(d);
let pick = |s: &mut Session, at: usize| {
s.run("select.range", &json!({"anchor": {"block": 0, "off": at}, "focus": {"block": 0, "off": at + 3}})).unwrap();
};
let fx_at = |s: &mut Session, at: usize| {
pick(s, at);
match selected(s).unwrap().1 {
InlineObject::Image { fx, media, .. } => (fx, media),
o => panic!("{o:?}"),
}
};
assert_eq!(fx_at(&mut s, 0).0, washout, "A");
assert_eq!(fx_at(&mut s, 3).0, PicFx::default(), "B");
for at in [0, 3] {
pick(&mut s, at);
s.run("picture.transparency", &json!({"percent": 30})).unwrap();
}
assert_eq!(fx_at(&mut s, 0).1, fx_at(&mut s, 3).1, "the same baked bytes");
pick(&mut s, 3);
s.run("picture.reset", &json!({})).unwrap();
assert_eq!(fx_at(&mut s, 3), (PicFx::default(), media.clone()));
assert_eq!(fx_at(&mut s, 0).0, washout, "A keeps its own until reset");
}
/// #2052: a picture style picked after another replaces it (Thick Frame then Simple Frame is
/// only the simple frame), a border replaces the border and keeps the style, an artistic
/// effect replaces the effect, and transparency 50% then 10% is 10%, as in Word.
+3 -38
View File
@@ -286,28 +286,21 @@ mod tests {
}
#[test]
fn set_as_default_sets_the_pref_and_nothing_else() {
fn preferred_only_sets_the_pref_and_nothing_else() {
with_backend(FixedBackend::sample(), |s, _| {
s.run("document.setText", &json!({"text": "Hello"})).unwrap();
s.run("select.all", &json!({})).unwrap();
let (doc, sel) = (s.doc.styles.default_chr.lang.clone(), s.sel.clone());
let normal = s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone());
// A redo step exists: the setting is no edit, so it neither adds an undo step nor clears it.
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
let rev = s.rev();
let (depth, redo, dirty) = (s.undo_depth(), s.can_redo(), s.dirty);
assert!(redo);
s.run("review.language.preferred", &json!({"lang": "nl-NL"})).unwrap();
assert_eq!((s.undo_depth(), s.can_redo(), s.dirty), (depth, redo, dirty), "history, redo and modified flag are kept");
s.run("review.language", &json!({"lang": "nl-NL", "preferredOnly": true})).unwrap();
assert_eq!(s.prefs.default_language.as_deref(), Some("nl-NL"));
assert_eq!(s.doc.styles.default_chr.lang, doc);
assert_eq!(s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone()), normal);
assert_eq!(s.sel, sel);
assert_eq!(packs::document_languages(s), ["en-US"], "the text keeps its language");
assert_eq!(s.rev(), rev);
assert!(s.run("review.language.preferred", &json!({})).is_err());
assert!(s.run("review.language.preferred", &json!({"lang": "not a tag!!"})).is_err());
assert!(s.run("review.language", &json!({"preferredOnly": true})).is_err());
});
}
@@ -662,34 +655,6 @@ mod tests {
assert_eq!(messages(pt_pt), messages(ProofLang::Pt), "Portugal text gets the Portuguese grammar checks");
}
/// The one region test and region-dropping step the engine and the UI share: two letters or
/// three digits after the language; hostile text never panics.
#[test]
fn a_region_is_two_letters_or_three_digits() {
for r in ["NL", "nl", "419", "ZZ"] {
assert!(packs::is_region(r), "{r}");
}
for r in ["", "n", "nld", "4190", "41", "Latn", "valencia", "é1", "\u{202e}\u{202e}"] {
assert!(!packs::is_region(r), "{r:?}");
}
for (tag, bare) in [
("nl-NL", "nl"),
("sr-Latn-RS", "sr-Latn"),
("ca-ES-valencia", "ca-valencia"),
("es_419", "es"),
("pt", "pt"),
("en-US-x-test", "en-x-test"),
("", ""),
("-", "-"),
] {
assert_eq!(packs::without_region(tag), bare, "{tag}");
}
// The language itself is never a region, even when it looks like one.
assert_eq!(packs::without_region("de"), "de");
let _ = packs::without_region(&"-".repeat(10_000));
let _ = packs::without_region("\u{0}-\u{202e}");
}
/// A font store that has never fetched the list (a fresh install), or whose cached list is
/// for an older pin: `index` has nothing until a fetch.
struct Uncached(FixedBackend);
+1 -2
View File
@@ -1,7 +1,6 @@
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::collate::{SortKey, sort_key};
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
@@ -257,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
let id = style_id(s, p::req_str(v, "style")?)?;
let on = p::bool(v, "value").unwrap_or(true);
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
if let Some(st) = s.doc.styles.get_mut(&id) {
st.quick = on;
}
sel_result(s)
+3 -4
View File
@@ -1,6 +1,5 @@
use super::format::fmt;
use super::{CmdError, CmdResult, LineSpacing, ListKind, NumRef, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
use std::sync::Arc;
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
@@ -46,9 +45,9 @@ pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
None => match kind {
ListKind::Bullet | ListKind::BulletChar(_) => match s.doc.numbering.find_kind(kind) {
Some(n) => n,
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
None => s.doc.numbering.try_add_list(kind)?,
},
_ => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
_ => s.doc.numbering.try_add_list(kind)?,
},
};
fmt(s, &|p| {
@@ -69,7 +68,7 @@ pub(super) fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else {
return Err(CmdError::Failed("not in a numbered list".into()));
};
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
// This paragraph and the following ones of the same list use the new instance.
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect();
for path in paths {
+1 -2
View File
@@ -1,6 +1,5 @@
use super::lists::touch_all;
use super::{Block, CmdError, CmdResult, NumRef, ParaProps, Pos, Session, SortKey, Value, p, sel_result, sort_key};
use std::sync::Arc;
/// Move Paragraph Up / Down (Alt+Shift+Up/Down, ⌃⇧↑/↓ on macOS): the paragraphs the selection
/// touches swap places with the paragraph above or below, in their own container (the story or
@@ -97,7 +96,7 @@ pub(crate) fn ensure_heading_style(s: &mut Session, n: u8) -> String {
if s.doc.styles.get(&sid).is_none()
&& let Some(st) = builtin.get(&sid).cloned()
{
Arc::make_mut(&mut s.doc.styles).upsert(st);
s.doc.styles.upsert(st);
}
}
touch_all(s);

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